diff --git a/.agent/skills b/.agent/skills new file mode 120000 index 00000000000000..2cd5a6932fa9ca --- /dev/null +++ b/.agent/skills @@ -0,0 +1 @@ +../.claude/skills/ \ No newline at end of file diff --git a/.claude/skills/github-pr-reviewer/SKILL.md b/.claude/skills/github-pr-reviewer/SKILL.md new file mode 100644 index 00000000000000..3d3586eb0f45ce --- /dev/null +++ b/.claude/skills/github-pr-reviewer/SKILL.md @@ -0,0 +1,46 @@ +--- +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. +--- + +# 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. +3. Analyze the code changes for: + - Code quality and style consistency + - Potential bugs or issues + - Performance implications + - Security concerns + - Test coverage + - Documentation updates if needed +4. Ensure any existing review comments have been addressed. +5. Generate constructive review comments in the CONSOLE. DO NOT POST TO GITHUB YOURSELF. + +## IMPORTANT: +- Just review. DO NOT make any changes +- Be constructive and specific in your comments +- Suggest improvements where appropriate +- Only provide review feedback in the CONSOLE. DO NOT ACT ON GITHUB. +- No need to run tests or linters, just review the code changes. +- No need to highlight things that are already good. + +## Output format: +- List specific comments for each file/line that needs attention +- In the end, summarize with an overall assessment (approve, request changes, or comment) and bullet point list of changes suggested, if any. + - Example output: + ``` + Overall assessment: request changes. + - [CRITICAL] Memory leak in homeassistant/components/sensor/my_sensor.py:143 + - [PROBLEM] Inefficient algorithm in homeassistant/helpers/data_processing.py:87 + - [SUGGESTION] Improve variable naming in homeassistant/helpers/config_validation.py:45 + ``` diff --git a/.claude/skills/integrations/SKILL.md b/.claude/skills/integrations/SKILL.md index b8fa703be74f4b..2bf861a9c8b963 100644 --- a/.claude/skills/integrations/SKILL.md +++ b/.claude/skills/integrations/SKILL.md @@ -620,12 +620,14 @@ rules: ### 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): diff --git a/.core_files.yaml b/.core_files.yaml index ab763b77086be6..62a787df0fd96e 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -34,6 +34,7 @@ base_platforms: &base_platforms - homeassistant/components/humidifier/** - homeassistant/components/image/** - homeassistant/components/image_processing/** + - homeassistant/components/infrared/** - homeassistant/components/lawn_mower/** - homeassistant/components/light/** - homeassistant/components/lock/** diff --git a/.gemini/skills b/.gemini/skills new file mode 120000 index 00000000000000..454b8427cd757f --- /dev/null +++ b/.gemini/skills @@ -0,0 +1 @@ +../.claude/skills \ No newline at end of file diff --git a/.gitattributes b/.gitattributes index 6a18819be9d01c..606d683762889d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -16,6 +16,7 @@ Dockerfile.dev linguist-language=Dockerfile CODEOWNERS linguist-generated=true Dockerfile linguist-generated=true homeassistant/generated/*.py linguist-generated=true +machine/* linguist-generated=true mypy.ini linguist-generated=true requirements.txt linguist-generated=true requirements_all.txt linguist-generated=true diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 435baa98c75b3a..8dc3b4000a8bd8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -7,1188 +7,27 @@ This repository contains the core of Home Assistant, a Python 3 based home autom ## Code Review Guidelines -**When reviewing code, do NOT comment on:** -- **Missing imports** - We use static analysis tooling to catch that -- **Code formatting** - We have ruff as a formatting tool that will catch those if needed (unless specifically instructed otherwise in these instructions) - **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 -## Python Requirements - -- **Compatibility**: Python 3.13+ -- **Language Features**: Use the newest features when possible: - - Pattern matching - - Type hints - - f-strings (preferred over `%` or `.format()`) - - Dataclasses - - Walrus operator - -### Strict Typing (Platinum) -- **Comprehensive Type Hints**: Add type hints to all functions, methods, and variables -- **Custom Config Entry Types**: When using runtime_data: - ```python - type MyIntegrationConfigEntry = ConfigEntry[MyClient] - ``` -- **Library Requirements**: Include `py.typed` file for PEP-561 compliance - -## Code Quality Standards - -- **Formatting**: Ruff -- **Linting**: PyLint and Ruff -- **Type Checking**: MyPy -- **Lint/Type/Format Fixes**: Always prefer addressing the underlying issue (e.g., import the typed source, update shared stubs, align with Ruff expectations, or correct formatting at the source) before disabling a rule, adding `# type: ignore`, or skipping a formatter. Treat suppressions and `noqa` comments as a last resort once no compliant fix exists -- **Testing**: pytest with plain functions and fixtures -- **Language**: American English for all code, comments, and documentation (use sentence case, including titles) - -### Writing Style Guidelines -- **Tone**: Friendly and informative -- **Perspective**: Use second-person ("you" and "your") for user-facing messages -- **Inclusivity**: Use objective, non-discriminatory language -- **Clarity**: Write for non-native English speakers -- **Formatting in Messages**: - - Use backticks for: file paths, filenames, variable names, field entries - - Use sentence case for titles and messages (capitalize only the first word and proper nouns) - - Avoid abbreviations when possible - -### Documentation Standards -- **File Headers**: Short and concise - ```python - """Integration for Peblar EV chargers.""" - ``` -- **Method/Function Docstrings**: Required for all - ```python - async def async_setup_entry(hass: HomeAssistant, entry: PeblarConfigEntry) -> bool: - """Set up Peblar from a config entry.""" - ``` -- **Comment Style**: - - Use clear, descriptive comments - - Explain the "why" not just the "what" - - Keep code block lines under 80 characters when possible - - Use progressive disclosure (simple explanation first, complex details later) - -## Async Programming - -- All external I/O operations must be async -- **Best Practices**: - - Avoid sleeping in loops - - Avoid awaiting in loops - use `gather` instead - - No blocking calls - - Group executor jobs when possible - switching between event loop and executor is expensive - -### Blocking Operations -- **Use Executor**: For blocking I/O operations - ```python - result = await hass.async_add_executor_job(blocking_function, args) - ``` -- **Never Block Event Loop**: Avoid file operations, `time.sleep()`, blocking HTTP calls -- **Replace with Async**: Use `asyncio.sleep()` instead of `time.sleep()` - -### Thread Safety -- **@callback Decorator**: For event loop safe functions - ```python - @callback - def async_update_callback(self, event): - """Safe to run in event loop.""" - self.async_write_ha_state() - ``` -- **Sync APIs from Threads**: Use sync versions when calling from non-event loop threads -- **Registry Changes**: Must be done in event loop thread - -### Error Handling -- **Exception Types**: Choose most specific exception available - - `ServiceValidationError`: User input errors (preferred over `ValueError`) - - `HomeAssistantError`: Device communication failures - - `ConfigEntryNotReady`: Temporary setup issues (device offline) - - `ConfigEntryAuthFailed`: Authentication problems - - `ConfigEntryError`: Permanent setup issues -- **Try/Catch Best Practices**: - - Only wrap code that can throw exceptions - - Keep try blocks minimal - process data after the try/catch - - **Avoid bare exceptions** except in specific cases: - - ❌ Generally not allowed: `except:` or `except Exception:` - - ✅ Allowed in config flows to ensure robustness - - ✅ Allowed in functions/methods that run in background tasks - - Bad pattern: - ```python - try: - data = await device.get_data() # Can throw - # ❌ Don't process data inside try block - processed = data.get("value", 0) * 100 - self._attr_native_value = processed - except DeviceError: - _LOGGER.error("Failed to get data") - ``` - - Good pattern: - ```python - try: - data = await device.get_data() # Can throw - except DeviceError: - _LOGGER.error("Failed to get data") - return - - # ✅ Process data outside try block - processed = data.get("value", 0) * 100 - self._attr_native_value = processed - ``` -- **Bare Exception Usage**: - ```python - # ❌ Not allowed in regular code - try: - data = await device.get_data() - except Exception: # Too broad - _LOGGER.error("Failed") - - # ✅ Allowed in config flow for robustness - async def async_step_user(self, user_input=None): - try: - await self._test_connection(user_input) - except Exception: # Allowed here - errors["base"] = "unknown" - - # ✅ Allowed in background tasks - async def _background_refresh(): - try: - await coordinator.async_refresh() - except Exception: # Allowed in task - _LOGGER.exception("Unexpected error in background task") - ``` -- **Setup Failure Patterns**: - ```python - try: - await device.async_setup() - except (asyncio.TimeoutError, TimeoutException) as ex: - raise ConfigEntryNotReady(f"Timeout connecting to {device.host}") from ex - except AuthFailed as ex: - raise ConfigEntryAuthFailed(f"Credentials expired for {device.name}") from ex - ``` - -### Logging -- **Format Guidelines**: - - No periods at end of messages - - No integration names/domains (added automatically) - - No sensitive data (keys, tokens, passwords) -- Use debug level for non-user-facing messages -- **Use Lazy Logging**: - ```python - _LOGGER.debug("This is a log message with %s", variable) - ``` - -### Unavailability Logging -- **Log Once**: When device/service becomes unavailable (info level) -- **Log Recovery**: When device/service comes back online -- **Implementation Pattern**: - ```python - _unavailable_logged: bool = False - - if not self._unavailable_logged: - _LOGGER.info("The sensor is unavailable: %s", ex) - self._unavailable_logged = True - # On recovery: - if self._unavailable_logged: - _LOGGER.info("The sensor is back online") - self._unavailable_logged = False - ``` - ## Development Commands -### Environment -- **Local development (non-container)**: Activate the project venv before running commands: `source .venv/bin/activate` -- **Dev container**: No activation needed, the environment is pre-configured - -### Code Quality & Linting -- **Run all linters on all files**: `prek run --all-files` -- **Run linters on staged files only**: `prek run` -- **PyLint on everything** (slow): `pylint homeassistant` -- **PyLint on specific folder**: `pylint homeassistant/components/my_integration` -- **MyPy type checking (whole project)**: `mypy homeassistant/` -- **MyPy on specific integration**: `mypy homeassistant/components/my_integration` - -### Testing -- **Quick test of changed files**: `pytest --timeout=10 --picked` -- **Update test snapshots**: Add `--snapshot-update` to pytest command - - ⚠️ Omit test results after using `--snapshot-update` - - Always run tests again without the flag to verify snapshots -- **Full test suite** (AVOID - very slow): `pytest ./tests` - -### Dependencies & Requirements -- **Update generated files after dependency changes**: `python -m script.gen_requirements_all` -- **Install all Python requirements**: - ```bash - uv pip install -r requirements_all.txt -r requirements.txt -r requirements_test.txt - ``` -- **Install test requirements only**: - ```bash - uv pip install -r requirements_test_all.txt -r requirements.txt - ``` - -### Translations -- **Update translations after strings.json changes**: - ```bash - python -m script.translations develop --all - ``` - -### Project Validation -- **Run hassfest** (checks project structure and updates generated files): - ```bash - python -m script.hassfest - ``` - -## Common Anti-Patterns & Best Practices - -### ❌ **Avoid These Patterns** -```python -# Blocking operations in event loop -data = requests.get(url) # ❌ Blocks event loop -time.sleep(5) # ❌ Blocks event loop - -# Reusing BleakClient instances -self.client = BleakClient(address) -await self.client.connect() -# Later... -await self.client.connect() # ❌ Don't reuse - -# Hardcoded strings in code -self._attr_name = "Temperature Sensor" # ❌ Not translatable - -# Missing error handling -data = await self.api.get_data() # ❌ No exception handling - -# Storing sensitive data in diagnostics -return {"api_key": entry.data[CONF_API_KEY]} # ❌ Exposes secrets - -# Accessing hass.data directly in tests -coordinator = hass.data[DOMAIN][entry.entry_id] # ❌ Don't access hass.data - -# User-configurable polling intervals -# In config flow -vol.Optional("scan_interval", default=60): cv.positive_int # ❌ Not allowed -# In coordinator -update_interval = timedelta(minutes=entry.data.get("scan_interval", 1)) # ❌ Not allowed - -# User-configurable config entry names (non-helper integrations) -vol.Optional("name", default="My Device"): cv.string # ❌ Not allowed in regular integrations - -# Too much code in try block -try: - response = await client.get_data() # Can throw - # ❌ Data processing should be outside try block - temperature = response["temperature"] / 10 - humidity = response["humidity"] - self._attr_native_value = temperature -except ClientError: - _LOGGER.error("Failed to fetch data") - -# Bare exceptions in regular code -try: - value = await sensor.read_value() -except Exception: # ❌ Too broad - catch specific exceptions - _LOGGER.error("Failed to read sensor") -``` - -### ✅ **Use These Patterns Instead** -```python -# Async operations with executor -data = await hass.async_add_executor_job(requests.get, url) -await asyncio.sleep(5) # ✅ Non-blocking - -# Fresh BleakClient instances -client = BleakClient(address) # ✅ New instance each time -await client.connect() - -# Translatable entity names -_attr_translation_key = "temperature_sensor" # ✅ Translatable - -# Proper error handling -try: - data = await self.api.get_data() -except ApiException as err: - raise UpdateFailed(f"API error: {err}") from err - -# Redacted diagnostics data -return async_redact_data(data, {"api_key", "password"}) # ✅ Safe - -# Test through proper integration setup and fixtures -@pytest.fixture -async def init_integration(hass, mock_config_entry, mock_api): - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) # ✅ Proper setup - -# Integration-determined polling intervals (not user-configurable) -SCAN_INTERVAL = timedelta(minutes=5) # ✅ Common pattern: constant in const.py - -class MyCoordinator(DataUpdateCoordinator[MyData]): - def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None: - # ✅ Integration determines interval based on device capabilities, connection type, etc. - interval = timedelta(minutes=1) if client.is_local else SCAN_INTERVAL - super().__init__( - hass, - logger=LOGGER, - name=DOMAIN, - update_interval=interval, - config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended - ) -``` - - -# Skill: Home Assistant Integration knowledge - -### 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 - -# 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 - - -- **Repairs**: [`platform-repairs.md`](platform-repairs.md) for user-actionable repair issues - -# 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 - - - -### 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 -- **Test Scenarios**: - - All flow initiation methods (user, discovery, import) - - Successful configuration paths - - Error recovery scenarios - - Prevention of duplicate entries - - Flow completion after errors - -### 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) +.vscode/tasks.json contains useful commands used for development. - 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() +## Python Syntax Notes - return mock_config_entry -``` +- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses. -## Debugging & Troubleshooting +## Testing -### 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 +When writing or modifying tests, ensure all test function parameters have type annotations. +Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) over `Any`. -### Debug Logging Setup -```python -# Enable debug logging in tests -caplog.set_level(logging.DEBUG, logger="my_integration") +## Good practices -# In integration code - use proper logging -_LOGGER = logging.getLogger(__name__) -_LOGGER.debug("Processing data: %s", data) # Use lazy logging -``` +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. -### 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 +# Skills -# Run integration tests with coverage -pytest ./tests/components/my_integration \ - --cov=homeassistant.components.my_integration \ - --cov-report term-missing -``` +- Home Assistant Integration knowledge: .claude/skills/integrations/SKILL.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f9bfa9b406dd10..e04aba50e62025 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,3 +9,5 @@ updates: labels: - dependency - github_actions + cooldown: + default-days: 7 diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 3420bbb174c3ef..071f2813ca69cb 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -10,7 +10,6 @@ on: env: BUILD_TYPE: core - DEFAULT_PYTHON: "3.14.2" PIP_TIMEOUT: 60 UV_HTTP_TIMEOUT: 60 UV_SYSTEM_PYTHON: "true" @@ -20,41 +19,46 @@ env: permissions: {} +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: init: name: Initialize build if: github.repository_owner == 'home-assistant' runs-on: ubuntu-latest permissions: - contents: read + contents: read # To check out the repository outputs: version: ${{ steps.version.outputs.version }} channel: ${{ steps.version.outputs.channel }} publish: ${{ steps.version.outputs.publish }} architectures: ${{ env.ARCHITECTURES }} + base_image_version: ${{ env.BASE_IMAGE_VERSION }} steps: - name: Checkout the repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - name: Set up Python ${{ env.DEFAULT_PYTHON }} + - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: ${{ env.DEFAULT_PYTHON }} + python-version-file: ".python-version" - name: Get information id: info - uses: home-assistant/actions/helpers/info@master + uses: home-assistant/actions/helpers/info@master # zizmor: ignore[unpinned-uses] - name: Get version id: version - uses: home-assistant/actions/helpers/version@master + uses: home-assistant/actions/helpers/version@master # zizmor: ignore[unpinned-uses] with: type: ${{ env.BUILD_TYPE }} - name: Verify version - uses: home-assistant/actions/helpers/verify-version@master + uses: home-assistant/actions/helpers/verify-version@master # zizmor: ignore[unpinned-uses] with: ignore-dev: true @@ -69,14 +73,14 @@ jobs: - name: Download Translations run: python3 -m script.translations download env: - LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} + LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env] - name: Archive translations shell: bash run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T - - name: Upload translations - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: translations path: translations.tar.gz @@ -88,16 +92,16 @@ jobs: needs: init runs-on: ${{ matrix.os }} permissions: - contents: read - packages: write - id-token: write + contents: read # To check out the repository + packages: write # To push to GHCR + id-token: write # For cosign signing strategy: fail-fast: false matrix: arch: ${{ fromJson(needs.init.outputs.architectures) }} include: - arch: amd64 - os: ubuntu-latest + os: ubuntu-24.04 - arch: aarch64 os: ubuntu-24.04-arm steps: @@ -108,7 +112,7 @@ jobs: - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@5c98f0b039f36ef966fdb7dfa9779262785ecb05 # v14 + uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/frontend @@ -119,7 +123,7 @@ jobs: - name: Download nightly wheels of intents if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@5c98f0b039f36ef966fdb7dfa9779262785ecb05 # v14 + uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: OHF-Voice/intents-package @@ -128,11 +132,11 @@ jobs: workflow_conclusion: success name: package - - name: Set up Python ${{ env.DEFAULT_PYTHON }} + - name: Set up Python if: needs.init.outputs.channel == 'dev' uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: ${{ env.DEFAULT_PYTHON }} + python-version-file: ".python-version" - name: Adjust nightly version if: needs.init.outputs.channel == 'dev' @@ -178,7 +182,7 @@ jobs: fi - name: Download translations - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: translations @@ -192,87 +196,30 @@ jobs: run: | echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE - - name: Login to GitHub Container Registry - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Install Cosign - uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 - with: - cosign-release: "v2.5.3" - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - - name: Build variables - id: vars - shell: bash - env: - ARCH: ${{ matrix.arch }} - run: | - echo "base_image=ghcr.io/home-assistant/${ARCH}-homeassistant-base:${BASE_IMAGE_VERSION}" >> "$GITHUB_OUTPUT" - echo "cache_image=ghcr.io/home-assistant/${ARCH}-homeassistant:latest" >> "$GITHUB_OUTPUT" - echo "created=$(date --rfc-3339=seconds --utc)" >> "$GITHUB_OUTPUT" - - - name: Verify base image signature - env: - BASE_IMAGE: ${{ steps.vars.outputs.base_image }} - run: | - cosign verify \ - --certificate-oidc-issuer https://token.actions.githubusercontent.com \ - --certificate-identity-regexp "https://github.com/home-assistant/docker/.*" \ - "${BASE_IMAGE}" - - - name: Verify cache image signature - id: cache - continue-on-error: true - env: - CACHE_IMAGE: ${{ steps.vars.outputs.cache_image }} - run: | - cosign verify \ - --certificate-oidc-issuer https://token.actions.githubusercontent.com \ - --certificate-identity-regexp "https://github.com/home-assistant/core/.*" \ - "${CACHE_IMAGE}" - - name: Build base image - id: build - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2 with: - context: . - file: ./Dockerfile - platforms: ${{ steps.vars.outputs.platform }} - push: true - cache-from: ${{ steps.cache.outcome == 'success' && steps.vars.outputs.cache_image || '' }} + arch: ${{ matrix.arch }} build-args: | - BUILD_FROM=${{ steps.vars.outputs.base_image }} - tags: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }} - outputs: type=image,push=true,compression=zstd,compression-level=9,force-compression=true,oci-mediatypes=true - labels: | - io.hass.arch=${{ matrix.arch }} - io.hass.version=${{ needs.init.outputs.version }} - org.opencontainers.image.created=${{ steps.vars.outputs.created }} - org.opencontainers.image.version=${{ needs.init.outputs.version }} - - - name: Sign image - env: - ARCH: ${{ matrix.arch }} - VERSION: ${{ needs.init.outputs.version }} - DIGEST: ${{ steps.build.outputs.digest }} - run: | - cosign sign --yes "ghcr.io/home-assistant/${ARCH}-homeassistant:${VERSION}@${DIGEST}" + BUILD_FROM=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ needs.init.outputs.base_image_version }} + cache-gha: false + container-registry-password: ${{ secrets.GITHUB_TOKEN }} + cosign-base-identity: "https://github.com/home-assistant/docker/.*" + cosign-base-verify: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ needs.init.outputs.base_image_version }} + image: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant + image-tags: ${{ needs.init.outputs.version }} + push: true + version: ${{ needs.init.outputs.version }} build_machine: name: Build ${{ matrix.machine }} machine core image if: github.repository_owner == 'home-assistant' needs: ["init", "build_base"] - runs-on: ubuntu-latest + runs-on: ${{ matrix.runs-on }} permissions: - contents: read - packages: write - id-token: write + contents: read # To check out the repository + packages: write # To push to GHCR + id-token: write # For cosign signing strategy: matrix: machine: @@ -290,41 +237,59 @@ jobs: - raspberrypi5-64 - yellow - green + include: + # Default: aarch64 on native ARM runner + - arch: aarch64 + runs-on: ubuntu-24.04-arm + # Overrides for amd64 machines + - machine: generic-x86-64 + arch: amd64 + runs-on: ubuntu-24.04 + - machine: qemux86-64 + arch: amd64 + runs-on: ubuntu-24.04 + # TODO: remove, intel-nuc is a legacy name for x86-64, renamed in 2021 + - machine: intel-nuc + arch: amd64 + runs-on: ubuntu-24.04 steps: - name: Checkout the repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - name: Set build additional args + - name: Compute extra tags + id: tags + shell: bash env: VERSION: ${{ needs.init.outputs.version }} run: | - # Create general tags if [[ "${VERSION}" =~ d ]]; then - echo "BUILD_ARGS=--additional-tag dev" >> $GITHUB_ENV + echo "extra_tags=dev" >> "$GITHUB_OUTPUT" elif [[ "${VERSION}" =~ b ]]; then - echo "BUILD_ARGS=--additional-tag beta" >> $GITHUB_ENV + echo "extra_tags=beta" >> "$GITHUB_OUTPUT" else - echo "BUILD_ARGS=--additional-tag stable" >> $GITHUB_ENV + echo "extra_tags=stable" >> "$GITHUB_OUTPUT" fi - - name: Login to GitHub Container Registry - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - # home-assistant/builder doesn't support sha pinning - - name: Build base image - uses: home-assistant/builder@2025.11.0 + - name: Build machine image + uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2 with: - args: | - $BUILD_ARGS \ - --target /data/machine \ - --cosign \ - --machine "${{ needs.init.outputs.version }}=${{ matrix.machine }}" + arch: ${{ matrix.arch }} + build-args: | + BUILD_FROM=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }} + cache-gha: false + container-registry-password: ${{ secrets.GITHUB_TOKEN }} + context: machine/ + cosign-base-identity: "https://github.com/home-assistant/core/.*" + cosign-base-verify: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }} + file: machine/${{ matrix.machine }} + image: ghcr.io/home-assistant/${{ matrix.machine }}-homeassistant + image-tags: | + ${{ needs.init.outputs.version }} + ${{ steps.tags.outputs.extra_tags }} + push: true + version: ${{ needs.init.outputs.version }} publish_ha: name: Publish version files @@ -341,14 +306,14 @@ jobs: persist-credentials: false - name: Initialize git - uses: home-assistant/actions/helpers/git-init@master + uses: home-assistant/actions/helpers/git-init@master # zizmor: ignore[unpinned-uses] with: name: ${{ secrets.GIT_NAME }} email: ${{ secrets.GIT_EMAIL }} token: ${{ secrets.GIT_TOKEN }} - name: Update version file - uses: home-assistant/actions/helpers/version-push@master + uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses] with: key: "homeassistant[]" key-description: "Home Assistant Core" @@ -358,7 +323,7 @@ jobs: - name: Update version file (stable -> beta) if: needs.init.outputs.channel == 'stable' - uses: home-assistant/actions/helpers/version-push@master + uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses] with: key: "homeassistant[]" key-description: "Home Assistant Core" @@ -373,28 +338,28 @@ jobs: needs: ["init", "build_base"] runs-on: ubuntu-latest permissions: - contents: read - packages: write - id-token: write + contents: read # To check out the repository + packages: write # To push to GHCR + id-token: write # For cosign signing strategy: fail-fast: false matrix: registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"] steps: - name: Install Cosign - uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 + uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 with: cosign-release: "v2.5.3" - name: Login to DockerHub if: matrix.registry == 'docker.io/homeassistant' - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -424,7 +389,7 @@ jobs: # 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev - name: Generate Docker metadata id: meta - uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 with: images: ${{ matrix.registry }}/home-assistant sep-tags: "," @@ -438,7 +403,7 @@ jobs: type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.7.1 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1 - name: Copy architecture images to DockerHub if: matrix.registry == 'docker.io/homeassistant' @@ -510,8 +475,8 @@ jobs: needs: ["init", "build_base"] runs-on: ubuntu-latest permissions: - contents: read - id-token: write + contents: read # To check out the repository + id-token: write # For PyPI trusted publishing if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - name: Checkout the repository @@ -519,13 +484,13 @@ jobs: with: persist-credentials: false - - name: Set up Python ${{ env.DEFAULT_PYTHON }} + - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: ${{ env.DEFAULT_PYTHON }} + python-version-file: ".python-version" - name: Download translations - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: translations @@ -551,10 +516,10 @@ jobs: name: Build and test hassfest image runs-on: ubuntu-latest permissions: - contents: read - packages: write - attestations: write - id-token: write + contents: read # To check out the repository + packages: write # To push to GHCR + attestations: write # For build provenance attestation + id-token: write # For build provenance attestation needs: ["init"] if: github.repository_owner == 'home-assistant' env: @@ -567,14 +532,14 @@ jobs: persist-credentials: false - name: Login to GitHub Container Registry - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build Docker image - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile @@ -587,7 +552,7 @@ jobs: - name: Push Docker image if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' id: push - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile @@ -596,7 +561,7 @@ jobs: - name: Generate artifact attestation if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' - uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 with: subject-name: ${{ env.HASSFEST_IMAGE_NAME }} subject-digest: ${{ steps.push.outputs.digest }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6ba44a6636e714..0e19a27d368cca 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,12 +37,11 @@ on: type: boolean env: - CACHE_VERSION: 2 + CACHE_VERSION: 3 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 1 - HA_SHORT_VERSION: "2026.3" - DEFAULT_PYTHON: "3.14.2" - ALL_PYTHON_VERSIONS: "['3.14.2']" + HA_SHORT_VERSION: "2026.4" + 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) # 10.6 is the current long-term-support @@ -78,8 +77,8 @@ jobs: name: Collect information & changes data runs-on: ubuntu-24.04 permissions: - contents: read - pull-requests: read + contents: read # To check out the repository + pull-requests: read # For paths-filter to detect changed files outputs: # In case of issues with the partial run, use the following line instead: # test_full_suite: 'true' @@ -166,12 +165,16 @@ jobs: tests_glob="" lint_only="" skip_coverage="" + default_python=$(cat .python-version) + all_python_versions=$(jq -cn \ + --arg default_python "${default_python}" \ + --argjson additional_python_versions "${ADDITIONAL_PYTHON_VERSIONS}" \ + '[$default_python] + $additional_python_versions') if [[ "${INTEGRATION_CHANGES}" != "[]" ]]; then - # Create a file glob for the integrations - integrations_glob=$(echo "${INTEGRATION_CHANGES}" | jq -cSr '. | join(",")') - [[ "${integrations_glob}" == *","* ]] && integrations_glob="{${integrations_glob}}" + # Create a space-separated list of integrations + integrations_glob=$(echo "${INTEGRATION_CHANGES}" | jq -r '. | join(" ")') # Create list of testable integrations possible_integrations=$(echo "${INTEGRATION_CHANGES}" | jq -cSr '.[]') @@ -190,9 +193,8 @@ jobs: # Test group count should be 1, we don't split partial tests test_group_count=1 - # Create a file glob for the integrations tests - tests_glob=$(echo "${tests}" | jq -cSr '. | join(",")') - [[ "${tests_glob}" == *","* ]] && tests_glob="{${tests_glob}}" + # Create a space-separated list of test integrations + tests_glob=$(echo "${tests}" | jq -r '. | join(" ")') mariadb_groups="[]" postgresql_groups="[]" @@ -237,8 +239,8 @@ jobs: echo "mariadb_groups=${mariadb_groups}" >> $GITHUB_OUTPUT echo "postgresql_groups: ${postgresql_groups}" echo "postgresql_groups=${postgresql_groups}" >> $GITHUB_OUTPUT - echo "python_versions: ${ALL_PYTHON_VERSIONS}" - echo "python_versions=${ALL_PYTHON_VERSIONS}" >> $GITHUB_OUTPUT + echo "python_versions: ${all_python_versions}" + echo "python_versions=${all_python_versions}" >> $GITHUB_OUTPUT echo "test_full_suite: ${test_full_suite}" echo "test_full_suite=${test_full_suite}" >> $GITHUB_OUTPUT echo "integrations_glob: ${integrations_glob}" @@ -280,9 +282,29 @@ jobs: - name: Run prek uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1.1.1 env: - PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config + PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor RUFF_OUTPUT_FORMAT: github + zizmor: + name: Check GitHub Actions workflows + runs-on: ubuntu-24.04 + permissions: + contents: read # To check out the repository + needs: [info] + if: | + github.event.inputs.pylint-only != 'true' + && github.event.inputs.mypy-only != 'true' + && github.event.inputs.audit-licenses-only != 'true' + steps: + - name: Check out code from GitHub + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Run zizmor + uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1.1.1 + with: + extra-args: --all-files zizmor + lint-hadolint: name: Check ${{ matrix.file }} runs-on: ubuntu-24.04 @@ -309,7 +331,7 @@ jobs: run: | echo "::add-matcher::.github/workflows/matchers/hadolint.json" - name: Check ${{ matrix.file }} - uses: docker://hadolint/hadolint:v2.12.0 + uses: docker://hadolint/hadolint:v2.12.0@sha256:30a8fd2e785ab6176eed53f74769e04f125afb2f74a6c52aef7d463583b6d45e with: args: hadolint ${{ matrix.file }} @@ -434,7 +456,7 @@ jobs: python --version uv pip freeze >> pip_freeze.txt - name: Upload pip_freeze artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: pip-freeze-${{ matrix.python-version }} path: pip_freeze.txt @@ -485,13 +507,13 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - name: Set up Python ${{ env.DEFAULT_PYTHON }} + - name: Set up Python id: python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: ${{ env.DEFAULT_PYTHON }} + python-version-file: ".python-version" check-latest: true - - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment + - name: Restore full Python virtual environment id: cache-venv uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: @@ -522,13 +544,13 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - name: Set up Python ${{ env.DEFAULT_PYTHON }} + - name: Set up Python id: python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: ${{ env.DEFAULT_PYTHON }} + python-version-file: ".python-version" check-latest: true - - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment + - name: Restore full Python virtual environment id: cache-venv uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: @@ -558,11 +580,11 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - name: Set up Python ${{ env.DEFAULT_PYTHON }} + - name: Set up Python id: python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: ${{ env.DEFAULT_PYTHON }} + python-version-file: ".python-version" check-latest: true - name: Run gen_copilot_instructions.py run: | @@ -587,7 +609,7 @@ jobs: with: persist-credentials: false - name: Dependency review - uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2 + uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 with: license-check: false # We use our own license audit checks @@ -635,7 +657,7 @@ jobs: . venv/bin/activate python -m script.licenses extract --output-file=licenses-${PYTHON_VERSION}.json - name: Upload licenses - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: licenses-${{ github.run_number }}-${{ matrix.python-version }} path: licenses-${{ matrix.python-version }}.json @@ -664,13 +686,13 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - name: Set up Python ${{ env.DEFAULT_PYTHON }} + - name: Set up Python id: python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: ${{ env.DEFAULT_PYTHON }} + python-version-file: ".python-version" check-latest: true - - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment + - name: Restore full Python virtual environment id: cache-venv uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: @@ -696,7 +718,7 @@ jobs: run: | . venv/bin/activate python --version - pylint --ignore-missing-annotations=y homeassistant/components/${INTEGRATIONS_GLOB} + pylint --ignore-missing-annotations=y $(printf "homeassistant/components/%s " ${INTEGRATIONS_GLOB}) pylint-tests: name: Check pylint on tests @@ -717,13 +739,13 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - name: Set up Python ${{ env.DEFAULT_PYTHON }} + - name: Set up Python id: python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: ${{ env.DEFAULT_PYTHON }} + python-version-file: ".python-version" check-latest: true - - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment + - name: Restore full Python virtual environment id: cache-venv uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: @@ -749,7 +771,7 @@ jobs: run: | . venv/bin/activate python --version - pylint tests/components/${TESTS_GLOB} + pylint $(printf "tests/components/%s " ${TESTS_GLOB}) mypy: name: Check mypy @@ -768,11 +790,11 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - name: Set up Python ${{ env.DEFAULT_PYTHON }} + - name: Set up Python id: python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: ${{ env.DEFAULT_PYTHON }} + python-version-file: ".python-version" check-latest: true - name: Generate partial mypy restore key id: generate-mypy-key @@ -780,7 +802,7 @@ jobs: mypy_version=$(cat requirements_test.txt | grep 'mypy.*=' | cut -d '=' -f 3) echo "version=${mypy_version}" >> $GITHUB_OUTPUT 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 ${{ env.DEFAULT_PYTHON }} virtual environment + - name: Restore full Python virtual environment id: cache-venv uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: @@ -817,7 +839,7 @@ jobs: run: | . venv/bin/activate python --version - mypy homeassistant/components/${INTEGRATIONS_GLOB} + mypy $(printf "homeassistant/components/%s " ${INTEGRATIONS_GLOB}) prepare-pytest-full: name: Split tests for full run @@ -830,10 +852,6 @@ jobs: needs: - info - base - - gen-requirements-all - - hassfest - - prek - - mypy steps: - name: Restore apt cache uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 @@ -861,13 +879,13 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - name: Set up Python ${{ env.DEFAULT_PYTHON }} + - name: Set up Python id: python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: ${{ env.DEFAULT_PYTHON }} + python-version-file: ".python-version" check-latest: true - - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment + - name: Restore full Python virtual environment id: cache-venv uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: @@ -883,7 +901,7 @@ jobs: . venv/bin/activate python -m script.split_tests ${TEST_GROUP_COUNT} tests - name: Upload pytest_buckets - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: pytest_buckets path: pytest_buckets.txt @@ -960,7 +978,7 @@ jobs: run: | echo "::add-matcher::.github/workflows/matchers/pytest-slow.json" - name: Download pytest_buckets - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: pytest_buckets - name: Compile English translations @@ -1002,14 +1020,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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml @@ -1022,7 +1040,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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: test-results-full-${{ matrix.python-version }}-${{ matrix.group }} path: junit.xml @@ -1039,7 +1057,7 @@ jobs: contents: read services: mariadb: - image: ${{ matrix.mariadb-group }} + image: ${{ matrix.mariadb-group }} # zizmor: ignore[unpinned-images] ports: - 3306:3306 env: @@ -1159,7 +1177,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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1167,7 +1185,7 @@ jobs: overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: coverage-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1181,7 +1199,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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: test-results-mariadb-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1197,7 +1215,7 @@ jobs: contents: read services: postgres: - image: ${{ matrix.postgresql-group }} + image: ${{ matrix.postgresql-group }} # zizmor: ignore[unpinned-images] ports: - 5432:5432 env: @@ -1320,7 +1338,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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1328,7 +1346,7 @@ jobs: overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: coverage-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1342,7 +1360,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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: test-results-postgres-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1369,7 +1387,7 @@ jobs: with: persist-credentials: false - name: Download all coverage artifacts - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: coverage-* - name: Upload coverage to Codecov @@ -1378,7 +1396,7 @@ jobs: with: fail_ci_if_error: true flags: full-suite - token: ${{ secrets.CODECOV_TOKEN }} + token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env] pytest-partial: name: Run tests Python ${{ matrix.python-version }} (${{ matrix.group }}) @@ -1496,14 +1514,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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml @@ -1516,7 +1534,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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: test-results-partial-${{ matrix.python-version }}-${{ matrix.group }} path: junit.xml @@ -1540,7 +1558,7 @@ jobs: with: persist-credentials: false - name: Download all coverage artifacts - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: coverage-* - name: Upload coverage to Codecov @@ -1548,7 +1566,7 @@ jobs: uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: fail_ci_if_error: true - token: ${{ secrets.CODECOV_TOKEN }} + token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env] upload-test-results: name: Upload test results to Codecov @@ -1561,7 +1579,7 @@ jobs: - pytest-mariadb timeout-minutes: 10 permissions: - id-token: write + id-token: write # For Codecov OIDC upload # codecov/test-results-action currently doesn't support tokenless uploads # therefore we can't run it on forks if: | @@ -1569,7 +1587,7 @@ jobs: && needs.info.outputs.skip_coverage != 'true' && !cancelled() steps: - name: Download all coverage artifacts - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: test-results-* - name: Upload test results to Codecov diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d8ce3b83f117e4..b0d1025642ed0c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -17,9 +17,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 360 permissions: - actions: read - contents: read - security-events: write + actions: read # To read workflow information for CodeQL + contents: read # To check out the repository + security-events: write # To upload CodeQL results steps: - name: Check out code from GitHub @@ -28,11 +28,11 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4.32.2 + uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4.32.2 + uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 with: category: "/language:python" diff --git a/.github/workflows/detect-duplicate-issues.yml b/.github/workflows/detect-duplicate-issues.yml index 81e828757092ca..8270a2040a968c 100644 --- a/.github/workflows/detect-duplicate-issues.yml +++ b/.github/workflows/detect-duplicate-issues.yml @@ -7,12 +7,16 @@ on: permissions: {} +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number }} + jobs: detect-duplicates: + name: Detect duplicate issues runs-on: ubuntu-latest permissions: - issues: write - models: read + issues: write # To comment on and label issues + models: read # For AI-based duplicate detection steps: - name: Check if integration label was added and extract details @@ -232,7 +236,7 @@ jobs: - name: Detect duplicates using AI id: ai_detection if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true' - uses: actions/ai-inference@a380166897b5408b8fb7dddd148142794cb5624a # v2.0.6 + uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7 with: model: openai/gpt-4o system-prompt: | diff --git a/.github/workflows/detect-non-english-issues.yml b/.github/workflows/detect-non-english-issues.yml index 34e5be2e906ce6..cab2b728b32184 100644 --- a/.github/workflows/detect-non-english-issues.yml +++ b/.github/workflows/detect-non-english-issues.yml @@ -7,12 +7,16 @@ on: permissions: {} +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number }} + jobs: detect-language: + name: Detect non-English issues runs-on: ubuntu-latest permissions: - issues: write - models: read + issues: write # To comment on, label, and close issues + models: read # For AI-based language detection steps: - name: Check issue language @@ -58,7 +62,7 @@ jobs: - name: Detect language using AI id: ai_language_detection if: steps.detect_language.outputs.should_continue == 'true' - uses: actions/ai-inference@a380166897b5408b8fb7dddd148142794cb5624a # v2.0.6 + uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7 with: model: openai/gpt-4o-mini system-prompt: | diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index cb69d77b2e231a..59ffbf324d78a1 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -7,13 +7,18 @@ on: permissions: {} +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + jobs: lock: + name: Lock inactive threads if: github.repository_owner == 'home-assistant' runs-on: ubuntu-latest permissions: - issues: write - pull-requests: write + issues: write # To lock issues + pull-requests: write # To lock pull requests steps: - uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0 with: diff --git a/.github/workflows/restrict-task-creation.yml b/.github/workflows/restrict-task-creation.yml index fdbe5c65635167..96828d06931b54 100644 --- a/.github/workflows/restrict-task-creation.yml +++ b/.github/workflows/restrict-task-creation.yml @@ -7,12 +7,37 @@ on: permissions: {} +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number }} + jobs: + add-no-stale: + name: Add no-stale label + runs-on: ubuntu-latest + permissions: + issues: write # To add labels to issues + if: >- + github.event.issue.type.name == 'Task' + || github.event.issue.type.name == 'Epic' + || github.event.issue.type.name == 'Opportunity' + steps: + - name: Add no-stale label + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['no-stale'] + }); + check-authorization: + name: Check authorization runs-on: ubuntu-latest permissions: - contents: read - issues: write + contents: read # To read CODEOWNERS file + issues: write # To comment on, label, and close issues # Only run if this is a Task issue type (from the issue form) if: github.event.issue.type.name == 'Task' steps: diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index c8eb41d0850daa..f8b8816e8f1367 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -8,13 +8,18 @@ on: permissions: {} +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + jobs: stale: + name: Mark stale issues and PRs if: github.repository_owner == 'home-assistant' runs-on: ubuntu-latest permissions: - issues: write - pull-requests: write + issues: write # To label and close stale issues + pull-requests: write # To label and close stale PRs steps: # The 60 day stale policy for PRs # Used for: @@ -22,7 +27,7 @@ jobs: # - No PRs marked as no-stale # - No issues (-1) - name: 60 days stale PRs policy - uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 + uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-stale: 60 @@ -53,8 +58,8 @@ jobs: # v1.7.0 uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a with: - app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} - private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} + app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env] + private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env] # The 90 day stale policy for issues # Used for: @@ -62,7 +67,7 @@ jobs: # - No issues marked as no-stale or help-wanted # - No PRs (-1) - name: 90 days stale issues - uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 + uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 with: repo-token: ${{ steps.token.outputs.token }} days-before-stale: 90 @@ -92,7 +97,7 @@ jobs: # - No Issues marked as no-stale or help-wanted # - No PRs (-1) - name: Needs more information stale issues policy - uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 + uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 with: repo-token: ${{ steps.token.outputs.token }} only-labels: "needs-more-information" diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index f8edc9f51e0b57..8d9d4f2e2da9fb 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -11,8 +11,9 @@ on: permissions: {} -env: - DEFAULT_PYTHON: "3.14.2" +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true jobs: upload: @@ -25,13 +26,13 @@ jobs: with: persist-credentials: false - - name: Set up Python ${{ env.DEFAULT_PYTHON }} + - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: ${{ env.DEFAULT_PYTHON }} + python-version-file: ".python-version" - name: Upload Translations env: - LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} + LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env] run: | python3 -m script.translations upload diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 9964d36adeda38..86ead98ad59aa2 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -16,9 +16,6 @@ on: - "requirements.txt" - "script/gen_requirements_all.py" -env: - DEFAULT_PYTHON: "3.14.2" - permissions: {} concurrency: @@ -36,11 +33,11 @@ jobs: with: persist-credentials: false - - name: Set up Python ${{ env.DEFAULT_PYTHON }} + - name: Set up Python id: python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: ${{ env.DEFAULT_PYTHON }} + python-version-file: ".python-version" check-latest: true - name: Create Python virtual environment @@ -77,7 +74,7 @@ jobs: ) > .env_file - name: Upload env_file - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: env_file path: ./.env_file @@ -85,7 +82,7 @@ jobs: overwrite: true - name: Upload requirements_diff - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: requirements_diff path: ./requirements_diff.txt @@ -97,7 +94,7 @@ jobs: python -m script.gen_requirements_all ci - name: Upload requirements_all_wheels - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: requirements_all_wheels path: ./requirements_all_wheels_*.txt @@ -110,7 +107,7 @@ jobs: strategy: fail-fast: false matrix: - abi: ["cp313", "cp314"] + abi: ["cp314"] arch: ["amd64", "aarch64"] include: - arch: amd64 @@ -124,12 +121,12 @@ jobs: persist-credentials: false - name: Download env_file - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: env_file - name: Download requirements_diff - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: requirements_diff @@ -145,7 +142,7 @@ jobs: abi: ${{ matrix.abi }} tag: musllinux_1_2 arch: ${{ matrix.arch }} - wheels-key: ${{ secrets.WHEELS_KEY }} + wheels-key: ${{ secrets.WHEELS_KEY }} # zizmor: ignore[secrets-outside-env] env-file: true apk: "libffi-dev;openssl-dev;yaml-dev;nasm;zlib-ng-dev" skip-binary: aiohttp;multidict;propcache;yarl;SQLAlchemy @@ -161,7 +158,7 @@ jobs: strategy: fail-fast: false matrix: - abi: ["cp313", "cp314"] + abi: ["cp314"] arch: ["amd64", "aarch64"] include: - arch: amd64 @@ -175,17 +172,17 @@ jobs: persist-credentials: false - name: Download env_file - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: env_file - name: Download requirements_diff - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: requirements_diff - name: Download requirements_all_wheels - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: requirements_all_wheels @@ -203,10 +200,10 @@ jobs: abi: ${{ matrix.abi }} tag: musllinux_1_2 arch: ${{ matrix.arch }} - wheels-key: ${{ secrets.WHEELS_KEY }} + wheels-key: ${{ secrets.WHEELS_KEY }} # zizmor: ignore[secrets-outside-env] env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev" skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" - requirements: "requirements_all.txt" + requirements: "requirements_all_wheels_${{ matrix.arch }}.txt" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 17dd38d51c0e89..018b971cbe2e51 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,6 +17,12 @@ repos: - --quiet-level=2 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 + hooks: + - id: zizmor + args: + - --pedantic - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: diff --git a/.python-version b/.python-version index 6324d401a069f4..95ed564f82b7ae 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.14 +3.14.2 diff --git a/.strict-typing b/.strict-typing index 34961f012c0cc9..05aec46bb79b6f 100644 --- a/.strict-typing +++ b/.strict-typing @@ -49,6 +49,7 @@ homeassistant.components.actiontec.* homeassistant.components.adax.* homeassistant.components.adguard.* homeassistant.components.aftership.* +homeassistant.components.ai_task.* homeassistant.components.air_quality.* homeassistant.components.airgradient.* homeassistant.components.airly.* @@ -122,7 +123,6 @@ homeassistant.components.blueprint.* homeassistant.components.bluesound.* homeassistant.components.bluetooth.* homeassistant.components.bluetooth_adapters.* -homeassistant.components.bmw_connected_drive.* homeassistant.components.bond.* homeassistant.components.bosch_alarm.* homeassistant.components.braviatv.* @@ -130,12 +130,14 @@ homeassistant.components.bring.* homeassistant.components.brother.* homeassistant.components.browser.* homeassistant.components.bryant_evolution.* +homeassistant.components.bsblan.* homeassistant.components.bthome.* homeassistant.components.button.* homeassistant.components.calendar.* homeassistant.components.cambridge_audio.* homeassistant.components.camera.* homeassistant.components.canary.* +homeassistant.components.casper_glow.* homeassistant.components.cert_expiry.* homeassistant.components.clickatell.* homeassistant.components.clicksend.* @@ -172,6 +174,7 @@ 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.* @@ -209,7 +212,9 @@ homeassistant.components.firefly_iii.* homeassistant.components.fitbit.* homeassistant.components.flexit_bacnet.* homeassistant.components.flux_led.* +homeassistant.components.folder_watcher.* homeassistant.components.forecast_solar.* +homeassistant.components.freshr.* homeassistant.components.fritz.* homeassistant.components.fritzbox.* homeassistant.components.fritzbox_callmonitor.* @@ -275,6 +280,7 @@ homeassistant.components.humidifier.* homeassistant.components.husqvarna_automower.* homeassistant.components.hydrawise.* homeassistant.components.hyperion.* +homeassistant.components.hypontech.* homeassistant.components.ibeacon.* homeassistant.components.idasen_desk.* homeassistant.components.image.* @@ -285,6 +291,7 @@ homeassistant.components.imgw_pib.* homeassistant.components.immich.* homeassistant.components.incomfort.* homeassistant.components.inels.* +homeassistant.components.infrared.* homeassistant.components.input_button.* homeassistant.components.input_select.* homeassistant.components.input_text.* @@ -297,6 +304,7 @@ homeassistant.components.iotty.* homeassistant.components.ipp.* homeassistant.components.iqvia.* homeassistant.components.iron_os.* +homeassistant.components.isal.* homeassistant.components.islamic_prayer_times.* homeassistant.components.isy994.* homeassistant.components.jellyfin.* @@ -307,6 +315,7 @@ homeassistant.components.knocki.* homeassistant.components.knx.* homeassistant.components.kraken.* homeassistant.components.kulersky.* +homeassistant.components.labs.* homeassistant.components.lacrosse.* homeassistant.components.lacrosse_view.* homeassistant.components.lamarzocco.* @@ -335,6 +344,7 @@ homeassistant.components.lookin.* homeassistant.components.lovelace.* homeassistant.components.luftdaten.* homeassistant.components.lunatone.* +homeassistant.components.lutron.* homeassistant.components.madvr.* homeassistant.components.manual.* homeassistant.components.mastodon.* @@ -366,6 +376,7 @@ homeassistant.components.my.* homeassistant.components.mysensors.* homeassistant.components.myuplink.* homeassistant.components.nam.* +homeassistant.components.namecheapdns.* homeassistant.components.nasweb.* homeassistant.components.neato.* homeassistant.components.nest.* @@ -401,6 +412,7 @@ homeassistant.components.opnsense.* homeassistant.components.opower.* homeassistant.components.oralb.* homeassistant.components.otbr.* +homeassistant.components.otp.* homeassistant.components.overkiz.* homeassistant.components.overseerr.* homeassistant.components.p1_monitor.* @@ -417,6 +429,7 @@ homeassistant.components.plugwise.* homeassistant.components.pooldose.* homeassistant.components.portainer.* homeassistant.components.powerfox.* +homeassistant.components.powerfox_local.* homeassistant.components.powerwall.* homeassistant.components.private_ble_device.* homeassistant.components.prometheus.* @@ -435,10 +448,12 @@ homeassistant.components.radarr.* homeassistant.components.radio_browser.* homeassistant.components.rainforest_raven.* homeassistant.components.rainmachine.* +homeassistant.components.random.* homeassistant.components.raspberry_pi.* homeassistant.components.rdw.* homeassistant.components.recollect_waste.* homeassistant.components.recorder.* +homeassistant.components.recovery_mode.* homeassistant.components.redgtech.* homeassistant.components.remember_the_milk.* homeassistant.components.remote.* @@ -470,6 +485,7 @@ homeassistant.components.schlage.* homeassistant.components.scrape.* homeassistant.components.script.* homeassistant.components.search.* +homeassistant.components.season.* homeassistant.components.select.* homeassistant.components.sensibo.* homeassistant.components.sensirion_ble.* @@ -496,6 +512,7 @@ homeassistant.components.smtp.* homeassistant.components.snooz.* homeassistant.components.solarlog.* homeassistant.components.sonarr.* +homeassistant.components.spaceapi.* homeassistant.components.speedtestdotnet.* homeassistant.components.spotify.* homeassistant.components.sql.* @@ -520,6 +537,7 @@ homeassistant.components.synology_dsm.* homeassistant.components.system_health.* homeassistant.components.system_log.* homeassistant.components.systemmonitor.* +homeassistant.components.systemnexa2.* homeassistant.components.tag.* homeassistant.components.tailscale.* homeassistant.components.tailwind.* @@ -530,6 +548,7 @@ homeassistant.components.tcp.* homeassistant.components.technove.* homeassistant.components.tedee.* homeassistant.components.telegram_bot.* +homeassistant.components.teslemetry.* homeassistant.components.text.* homeassistant.components.thethingsnetwork.* homeassistant.components.threshold.* @@ -553,6 +572,7 @@ homeassistant.components.trafikverket_train.* homeassistant.components.trafikverket_weatherstation.* homeassistant.components.transmission.* homeassistant.components.trend.* +homeassistant.components.trmnl.* homeassistant.components.tts.* homeassistant.components.twentemilieu.* homeassistant.components.unifi.* @@ -562,12 +582,14 @@ homeassistant.components.update.* homeassistant.components.uptime.* homeassistant.components.uptime_kuma.* homeassistant.components.uptimerobot.* +homeassistant.components.usage_prediction.* homeassistant.components.usb.* homeassistant.components.uvc.* homeassistant.components.vacuum.* homeassistant.components.vallox.* homeassistant.components.valve.* homeassistant.components.velbus.* +homeassistant.components.velux.* homeassistant.components.vivotek.* homeassistant.components.vlc_telnet.* homeassistant.components.vodafone_station.* @@ -580,6 +602,7 @@ homeassistant.components.water_heater.* homeassistant.components.watts.* homeassistant.components.watttime.* homeassistant.components.weather.* +homeassistant.components.web_rtc.* homeassistant.components.webhook.* homeassistant.components.webostv.* homeassistant.components.websocket_api.* @@ -596,6 +619,7 @@ homeassistant.components.yale_smart_alarm.* homeassistant.components.yalexs_ble.* homeassistant.components.youtube.* homeassistant.components.zeroconf.* +homeassistant.components.zinvolt.* homeassistant.components.zodiac.* homeassistant.components.zone.* homeassistant.components.zwave_js.* diff --git a/AGENTS.md b/AGENTS.md index bcf71447c99376..888d93ec07eaff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,325 +4,22 @@ This repository contains the core of Home Assistant, a Python 3 based home autom ## Code Review Guidelines -**When reviewing code, do NOT comment on:** -- **Missing imports** - We use static analysis tooling to catch that -- **Code formatting** - We have ruff as a formatting tool that will catch those if needed (unless specifically instructed otherwise in these instructions) - **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 -## Python Requirements - -- **Compatibility**: Python 3.13+ -- **Language Features**: Use the newest features when possible: - - Pattern matching - - Type hints - - f-strings (preferred over `%` or `.format()`) - - Dataclasses - - Walrus operator - -### Strict Typing (Platinum) -- **Comprehensive Type Hints**: Add type hints to all functions, methods, and variables -- **Custom Config Entry Types**: When using runtime_data: - ```python - type MyIntegrationConfigEntry = ConfigEntry[MyClient] - ``` -- **Library Requirements**: Include `py.typed` file for PEP-561 compliance - -## Code Quality Standards - -- **Formatting**: Ruff -- **Linting**: PyLint and Ruff -- **Type Checking**: MyPy -- **Lint/Type/Format Fixes**: Always prefer addressing the underlying issue (e.g., import the typed source, update shared stubs, align with Ruff expectations, or correct formatting at the source) before disabling a rule, adding `# type: ignore`, or skipping a formatter. Treat suppressions and `noqa` comments as a last resort once no compliant fix exists -- **Testing**: pytest with plain functions and fixtures -- **Language**: American English for all code, comments, and documentation (use sentence case, including titles) - -### Writing Style Guidelines -- **Tone**: Friendly and informative -- **Perspective**: Use second-person ("you" and "your") for user-facing messages -- **Inclusivity**: Use objective, non-discriminatory language -- **Clarity**: Write for non-native English speakers -- **Formatting in Messages**: - - Use backticks for: file paths, filenames, variable names, field entries - - Use sentence case for titles and messages (capitalize only the first word and proper nouns) - - Avoid abbreviations when possible - -### Documentation Standards -- **File Headers**: Short and concise - ```python - """Integration for Peblar EV chargers.""" - ``` -- **Method/Function Docstrings**: Required for all - ```python - async def async_setup_entry(hass: HomeAssistant, entry: PeblarConfigEntry) -> bool: - """Set up Peblar from a config entry.""" - ``` -- **Comment Style**: - - Use clear, descriptive comments - - Explain the "why" not just the "what" - - Keep code block lines under 80 characters when possible - - Use progressive disclosure (simple explanation first, complex details later) - -## Async Programming - -- All external I/O operations must be async -- **Best Practices**: - - Avoid sleeping in loops - - Avoid awaiting in loops - use `gather` instead - - No blocking calls - - Group executor jobs when possible - switching between event loop and executor is expensive - -### Blocking Operations -- **Use Executor**: For blocking I/O operations - ```python - result = await hass.async_add_executor_job(blocking_function, args) - ``` -- **Never Block Event Loop**: Avoid file operations, `time.sleep()`, blocking HTTP calls -- **Replace with Async**: Use `asyncio.sleep()` instead of `time.sleep()` - -### Thread Safety -- **@callback Decorator**: For event loop safe functions - ```python - @callback - def async_update_callback(self, event): - """Safe to run in event loop.""" - self.async_write_ha_state() - ``` -- **Sync APIs from Threads**: Use sync versions when calling from non-event loop threads -- **Registry Changes**: Must be done in event loop thread - -### Error Handling -- **Exception Types**: Choose most specific exception available - - `ServiceValidationError`: User input errors (preferred over `ValueError`) - - `HomeAssistantError`: Device communication failures - - `ConfigEntryNotReady`: Temporary setup issues (device offline) - - `ConfigEntryAuthFailed`: Authentication problems - - `ConfigEntryError`: Permanent setup issues -- **Try/Catch Best Practices**: - - Only wrap code that can throw exceptions - - Keep try blocks minimal - process data after the try/catch - - **Avoid bare exceptions** except in specific cases: - - ❌ Generally not allowed: `except:` or `except Exception:` - - ✅ Allowed in config flows to ensure robustness - - ✅ Allowed in functions/methods that run in background tasks - - Bad pattern: - ```python - try: - data = await device.get_data() # Can throw - # ❌ Don't process data inside try block - processed = data.get("value", 0) * 100 - self._attr_native_value = processed - except DeviceError: - _LOGGER.error("Failed to get data") - ``` - - Good pattern: - ```python - try: - data = await device.get_data() # Can throw - except DeviceError: - _LOGGER.error("Failed to get data") - return - - # ✅ Process data outside try block - processed = data.get("value", 0) * 100 - self._attr_native_value = processed - ``` -- **Bare Exception Usage**: - ```python - # ❌ Not allowed in regular code - try: - data = await device.get_data() - except Exception: # Too broad - _LOGGER.error("Failed") - - # ✅ Allowed in config flow for robustness - async def async_step_user(self, user_input=None): - try: - await self._test_connection(user_input) - except Exception: # Allowed here - errors["base"] = "unknown" - - # ✅ Allowed in background tasks - async def _background_refresh(): - try: - await coordinator.async_refresh() - except Exception: # Allowed in task - _LOGGER.exception("Unexpected error in background task") - ``` -- **Setup Failure Patterns**: - ```python - try: - await device.async_setup() - except (asyncio.TimeoutError, TimeoutException) as ex: - raise ConfigEntryNotReady(f"Timeout connecting to {device.host}") from ex - except AuthFailed as ex: - raise ConfigEntryAuthFailed(f"Credentials expired for {device.name}") from ex - ``` - -### Logging -- **Format Guidelines**: - - No periods at end of messages - - No integration names/domains (added automatically) - - No sensitive data (keys, tokens, passwords) -- Use debug level for non-user-facing messages -- **Use Lazy Logging**: - ```python - _LOGGER.debug("This is a log message with %s", variable) - ``` - -### Unavailability Logging -- **Log Once**: When device/service becomes unavailable (info level) -- **Log Recovery**: When device/service comes back online -- **Implementation Pattern**: - ```python - _unavailable_logged: bool = False - - if not self._unavailable_logged: - _LOGGER.info("The sensor is unavailable: %s", ex) - self._unavailable_logged = True - # On recovery: - if self._unavailable_logged: - _LOGGER.info("The sensor is back online") - self._unavailable_logged = False - ``` - ## Development Commands -### Environment -- **Local development (non-container)**: Activate the project venv before running commands: `source .venv/bin/activate` -- **Dev container**: No activation needed, the environment is pre-configured - -### Code Quality & Linting -- **Run all linters on all files**: `prek run --all-files` -- **Run linters on staged files only**: `prek run` -- **PyLint on everything** (slow): `pylint homeassistant` -- **PyLint on specific folder**: `pylint homeassistant/components/my_integration` -- **MyPy type checking (whole project)**: `mypy homeassistant/` -- **MyPy on specific integration**: `mypy homeassistant/components/my_integration` - -### Testing -- **Quick test of changed files**: `pytest --timeout=10 --picked` -- **Update test snapshots**: Add `--snapshot-update` to pytest command - - ⚠️ Omit test results after using `--snapshot-update` - - Always run tests again without the flag to verify snapshots -- **Full test suite** (AVOID - very slow): `pytest ./tests` - -### Dependencies & Requirements -- **Update generated files after dependency changes**: `python -m script.gen_requirements_all` -- **Install all Python requirements**: - ```bash - uv pip install -r requirements_all.txt -r requirements.txt -r requirements_test.txt - ``` -- **Install test requirements only**: - ```bash - uv pip install -r requirements_test_all.txt -r requirements.txt - ``` - -### Translations -- **Update translations after strings.json changes**: - ```bash - python -m script.translations develop --all - ``` - -### Project Validation -- **Run hassfest** (checks project structure and updates generated files): - ```bash - python -m script.hassfest - ``` - -## Common Anti-Patterns & Best Practices - -### ❌ **Avoid These Patterns** -```python -# Blocking operations in event loop -data = requests.get(url) # ❌ Blocks event loop -time.sleep(5) # ❌ Blocks event loop - -# Reusing BleakClient instances -self.client = BleakClient(address) -await self.client.connect() -# Later... -await self.client.connect() # ❌ Don't reuse - -# Hardcoded strings in code -self._attr_name = "Temperature Sensor" # ❌ Not translatable - -# Missing error handling -data = await self.api.get_data() # ❌ No exception handling - -# Storing sensitive data in diagnostics -return {"api_key": entry.data[CONF_API_KEY]} # ❌ Exposes secrets - -# Accessing hass.data directly in tests -coordinator = hass.data[DOMAIN][entry.entry_id] # ❌ Don't access hass.data - -# User-configurable polling intervals -# In config flow -vol.Optional("scan_interval", default=60): cv.positive_int # ❌ Not allowed -# In coordinator -update_interval = timedelta(minutes=entry.data.get("scan_interval", 1)) # ❌ Not allowed - -# User-configurable config entry names (non-helper integrations) -vol.Optional("name", default="My Device"): cv.string # ❌ Not allowed in regular integrations - -# Too much code in try block -try: - response = await client.get_data() # Can throw - # ❌ Data processing should be outside try block - temperature = response["temperature"] / 10 - humidity = response["humidity"] - self._attr_native_value = temperature -except ClientError: - _LOGGER.error("Failed to fetch data") - -# Bare exceptions in regular code -try: - value = await sensor.read_value() -except Exception: # ❌ Too broad - catch specific exceptions - _LOGGER.error("Failed to read sensor") -``` - -### ✅ **Use These Patterns Instead** -```python -# Async operations with executor -data = await hass.async_add_executor_job(requests.get, url) -await asyncio.sleep(5) # ✅ Non-blocking - -# Fresh BleakClient instances -client = BleakClient(address) # ✅ New instance each time -await client.connect() +.vscode/tasks.json contains useful commands used for development. -# Translatable entity names -_attr_translation_key = "temperature_sensor" # ✅ Translatable +## Python Syntax Notes -# Proper error handling -try: - data = await self.api.get_data() -except ApiException as err: - raise UpdateFailed(f"API error: {err}") from err +- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses. -# Redacted diagnostics data -return async_redact_data(data, {"api_key", "password"}) # ✅ Safe +## Testing -# Test through proper integration setup and fixtures -@pytest.fixture -async def init_integration(hass, mock_config_entry, mock_api): - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) # ✅ Proper setup +When writing or modifying tests, ensure all test function parameters have type annotations. +Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) over `Any`. -# Integration-determined polling intervals (not user-configurable) -SCAN_INTERVAL = timedelta(minutes=5) # ✅ Common pattern: constant in const.py +## Good practices -class MyCoordinator(DataUpdateCoordinator[MyData]): - def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None: - # ✅ Integration determines interval based on device capabilities, connection type, etc. - interval = timedelta(minutes=1) if client.is_local else SCAN_INTERVAL - super().__init__( - hass, - logger=LOGGER, - name=DOMAIN, - update_interval=interval, - config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended - ) -``` +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. diff --git a/CODEOWNERS b/CODEOWNERS index f81e1b94719cdf..03bafdd0b38d2f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -186,6 +186,8 @@ build.json @home-assistant/supervisor /tests/components/auth/ @home-assistant/core /homeassistant/components/automation/ @home-assistant/core /tests/components/automation/ @home-assistant/core +/homeassistant/components/autoskope/ @mcisk +/tests/components/autoskope/ @mcisk /homeassistant/components/avea/ @pattyland /homeassistant/components/awair/ @ahayworth @ricohageman /tests/components/awair/ @ahayworth @ricohageman @@ -234,14 +236,14 @@ build.json @home-assistant/supervisor /tests/components/bluetooth/ @bdraco /homeassistant/components/bluetooth_adapters/ @bdraco /tests/components/bluetooth_adapters/ @bdraco -/homeassistant/components/bmw_connected_drive/ @gerard33 @rikroe -/tests/components/bmw_connected_drive/ @gerard33 @rikroe /homeassistant/components/bond/ @bdraco @prystupa @joshs85 @marciogranzotto /tests/components/bond/ @bdraco @prystupa @joshs85 @marciogranzotto /homeassistant/components/bosch_alarm/ @mag1024 @sanjay900 /tests/components/bosch_alarm/ @mag1024 @sanjay900 /homeassistant/components/bosch_shc/ @tschamm /tests/components/bosch_shc/ @tschamm +/homeassistant/components/brands/ @home-assistant/core +/tests/components/brands/ @home-assistant/core /homeassistant/components/braviatv/ @bieniu @Drafteed /tests/components/braviatv/ @bieniu @Drafteed /homeassistant/components/bring/ @miaucl @tr4nt0r @@ -271,6 +273,8 @@ build.json @home-assistant/supervisor /tests/components/cambridge_audio/ @noahhusby /homeassistant/components/camera/ @home-assistant/core /tests/components/camera/ @home-assistant/core +/homeassistant/components/casper_glow/ @mikeodr +/tests/components/casper_glow/ @mikeodr /homeassistant/components/cast/ @emontnemery /tests/components/cast/ @emontnemery /homeassistant/components/ccm15/ @ocalvo @@ -279,6 +283,8 @@ build.json @home-assistant/supervisor /tests/components/cert_expiry/ @jjlawren /homeassistant/components/chacon_dio/ @cnico /tests/components/chacon_dio/ @cnico +/homeassistant/components/chess_com/ @joostlek +/tests/components/chess_com/ @joostlek /homeassistant/components/cisco_ios/ @fbradyirl /homeassistant/components/cisco_mobility_express/ @fbradyirl /homeassistant/components/cisco_webex_teams/ @fbradyirl @@ -381,6 +387,8 @@ build.json @home-assistant/supervisor /tests/components/dlna_dms/ @chishm /homeassistant/components/dnsip/ @gjohansson-ST /tests/components/dnsip/ @gjohansson-ST +/homeassistant/components/door/ @home-assistant/core +/tests/components/door/ @home-assistant/core /homeassistant/components/doorbird/ @oblogic7 @bdraco @flacjacket /tests/components/doorbird/ @oblogic7 @bdraco @flacjacket /homeassistant/components/dormakaba_dkey/ @emontnemery @@ -391,6 +399,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 @@ -399,12 +409,10 @@ build.json @home-assistant/supervisor /tests/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna /homeassistant/components/duckdns/ @tr4nt0r /tests/components/duckdns/ @tr4nt0r -/homeassistant/components/duke_energy/ @hunterjm -/tests/components/duke_energy/ @hunterjm /homeassistant/components/duotecno/ @cereal2nd /tests/components/duotecno/ @cereal2nd -/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @andarotajo -/tests/components/dwd_weather_warnings/ @runningman84 @stephan192 @andarotajo +/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 +/tests/components/dwd_weather_warnings/ @runningman84 @stephan192 /homeassistant/components/dynalite/ @ziv1234 /tests/components/dynalite/ @ziv1234 /homeassistant/components/eafm/ @Jc2k @@ -549,14 +557,14 @@ build.json @home-assistant/supervisor /tests/components/freebox/ @hacf-fr @Quentame /homeassistant/components/freedompro/ @stefano055415 /tests/components/freedompro/ @stefano055415 +/homeassistant/components/freshr/ @SierraNL +/tests/components/freshr/ @SierraNL /homeassistant/components/fressnapf_tracker/ @eifinger /tests/components/fressnapf_tracker/ @eifinger /homeassistant/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185 /tests/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185 /homeassistant/components/fritzbox/ @mib1185 @flabbamann /tests/components/fritzbox/ @mib1185 @flabbamann -/homeassistant/components/fritzbox_callmonitor/ @cdce8p -/tests/components/fritzbox_callmonitor/ @cdce8p /homeassistant/components/fronius/ @farmio /tests/components/fronius/ @farmio /homeassistant/components/frontend/ @home-assistant/frontend @@ -569,10 +577,14 @@ build.json @home-assistant/supervisor /tests/components/fully_kiosk/ @cgarwood /homeassistant/components/fyta/ @dontinelli /tests/components/fyta/ @dontinelli +/homeassistant/components/garage_door/ @home-assistant/core +/tests/components/garage_door/ @home-assistant/core /homeassistant/components/garages_amsterdam/ @klaasnicolaas /tests/components/garages_amsterdam/ @klaasnicolaas /homeassistant/components/gardena_bluetooth/ @elupus /tests/components/gardena_bluetooth/ @elupus +/homeassistant/components/gate/ @home-assistant/core +/tests/components/gate/ @home-assistant/core /homeassistant/components/gdacs/ @exxamalte /tests/components/gdacs/ @exxamalte /homeassistant/components/generic/ @davet2001 @@ -719,8 +731,8 @@ build.json @home-assistant/supervisor /tests/components/homematic/ @pvizeli /homeassistant/components/homematicip_cloud/ @hahn-th @lackas /tests/components/homematicip_cloud/ @hahn-th @lackas -/homeassistant/components/homevolt/ @danielhiversen -/tests/components/homevolt/ @danielhiversen +/homeassistant/components/homevolt/ @danielhiversen @liudger +/tests/components/homevolt/ @danielhiversen @liudger /homeassistant/components/homewizard/ @DCSBL /tests/components/homewizard/ @DCSBL /homeassistant/components/honeywell/ @rdfurman @mkmer @@ -739,6 +751,8 @@ build.json @home-assistant/supervisor /tests/components/huisbaasje/ @dennisschroer /homeassistant/components/humidifier/ @home-assistant/core @Shulyaka /tests/components/humidifier/ @home-assistant/core @Shulyaka +/homeassistant/components/humidity/ @home-assistant/core +/tests/components/humidity/ @home-assistant/core /homeassistant/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock /tests/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock /homeassistant/components/husqvarna_automower/ @Thomas55555 @@ -753,6 +767,8 @@ build.json @home-assistant/supervisor /tests/components/hydrawise/ @dknowles2 @thomaskistler @ptcryan /homeassistant/components/hyperion/ @dermotduffy /tests/components/hyperion/ @dermotduffy +/homeassistant/components/hypontech/ @jcisio +/tests/components/hypontech/ @jcisio /homeassistant/components/ialarm/ @RyuzakiKK /tests/components/ialarm/ @RyuzakiKK /homeassistant/components/iammeter/ @lewei50 @@ -786,10 +802,14 @@ build.json @home-assistant/supervisor /tests/components/improv_ble/ @emontnemery /homeassistant/components/incomfort/ @jbouwh /tests/components/incomfort/ @jbouwh +/homeassistant/components/indevolt/ @xirt +/tests/components/indevolt/ @xirt /homeassistant/components/inels/ @epdevlab /tests/components/inels/ @epdevlab -/homeassistant/components/influxdb/ @mdegat01 -/tests/components/influxdb/ @mdegat01 +/homeassistant/components/influxdb/ @mdegat01 @Robbie1221 +/tests/components/influxdb/ @mdegat01 @Robbie1221 +/homeassistant/components/infrared/ @home-assistant/core +/tests/components/infrared/ @home-assistant/core /homeassistant/components/inkbird/ @bdraco /tests/components/inkbird/ @bdraco /homeassistant/components/input_boolean/ @home-assistant/core @@ -929,6 +949,8 @@ build.json @home-assistant/supervisor /tests/components/lg_thinq/ @LG-ThinQ-Integration /homeassistant/components/libre_hardware_monitor/ @Sab44 /tests/components/libre_hardware_monitor/ @Sab44 +/homeassistant/components/lichess/ @aryanhasgithub +/tests/components/lichess/ @aryanhasgithub /homeassistant/components/lidarr/ @tkdrob /tests/components/lidarr/ @tkdrob /homeassistant/components/liebherr/ @mettolen @@ -958,6 +980,8 @@ build.json @home-assistant/supervisor /tests/components/logbook/ @home-assistant/core /homeassistant/components/logger/ @home-assistant/core /tests/components/logger/ @home-assistant/core +/homeassistant/components/lojack/ @devinslick +/tests/components/lojack/ @devinslick /homeassistant/components/london_underground/ @jpbede /tests/components/london_underground/ @jpbede /homeassistant/components/lookin/ @ANMalko @bdraco @@ -1057,6 +1081,8 @@ build.json @home-assistant/supervisor /tests/components/moon/ @fabaff @frenck /homeassistant/components/mopeka/ @bdraco /tests/components/mopeka/ @bdraco +/homeassistant/components/motion/ @home-assistant/core +/tests/components/motion/ @home-assistant/core /homeassistant/components/motion_blinds/ @starkillerOG /tests/components/motion_blinds/ @starkillerOG /homeassistant/components/motionblinds_ble/ @LennP @jerrybboy @@ -1068,6 +1094,8 @@ build.json @home-assistant/supervisor /homeassistant/components/mqtt/ @emontnemery @jbouwh @bdraco /tests/components/mqtt/ @emontnemery @jbouwh @bdraco /homeassistant/components/msteams/ @peroyvind +/homeassistant/components/mta/ @OnFreund +/tests/components/mta/ @OnFreund /homeassistant/components/mullvad/ @meichthys /tests/components/mullvad/ @meichthys /homeassistant/components/music_assistant/ @music-assistant @arturpragacz @@ -1076,6 +1104,8 @@ build.json @home-assistant/supervisor /tests/components/mutesync/ @currentoor /homeassistant/components/my/ @home-assistant/core /tests/components/my/ @home-assistant/core +/homeassistant/components/myneomitis/ @l-pr +/tests/components/myneomitis/ @l-pr /homeassistant/components/mysensors/ @MartinHjelmare @functionpointer /tests/components/mysensors/ @MartinHjelmare @functionpointer /homeassistant/components/mystrom/ @fabaff @@ -1092,8 +1122,8 @@ build.json @home-assistant/supervisor /tests/components/nasweb/ @nasWebio /homeassistant/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul /tests/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul -/homeassistant/components/ness_alarm/ @nickw444 -/tests/components/ness_alarm/ @nickw444 +/homeassistant/components/ness_alarm/ @nickw444 @poshy163 +/tests/components/ness_alarm/ @nickw444 @poshy163 /homeassistant/components/nest/ @allenporter /tests/components/nest/ @allenporter /homeassistant/components/netatmo/ @cgtobi @@ -1166,6 +1196,8 @@ build.json @home-assistant/supervisor /tests/components/nzbget/ @chriscla /homeassistant/components/obihai/ @dshokouhi @ejpenney /tests/components/obihai/ @dshokouhi @ejpenney +/homeassistant/components/occupancy/ @home-assistant/core +/tests/components/occupancy/ @home-assistant/core /homeassistant/components/octoprint/ @rfleming71 /tests/components/octoprint/ @rfleming71 /homeassistant/components/ohmconnect/ @robbiet480 @@ -1192,6 +1224,8 @@ build.json @home-assistant/supervisor /tests/components/open_meteo/ @frenck /homeassistant/components/open_router/ @joostlek /tests/components/open_router/ @joostlek +/homeassistant/components/opendisplay/ @g4bri3lDev +/tests/components/opendisplay/ @g4bri3lDev /homeassistant/components/openerz/ @misialq /tests/components/openerz/ @misialq /homeassistant/components/openevse/ @c00w @firstof9 @@ -1277,6 +1311,8 @@ build.json @home-assistant/supervisor /tests/components/portainer/ @erwindouna /homeassistant/components/powerfox/ @klaasnicolaas /tests/components/powerfox/ @klaasnicolaas +/homeassistant/components/powerfox_local/ @klaasnicolaas +/tests/components/powerfox_local/ @klaasnicolaas /homeassistant/components/powerwall/ @bdraco @jrester @daniel-simpson /tests/components/powerwall/ @bdraco @jrester @daniel-simpson /homeassistant/components/prana/ @prana-dev-official @@ -1295,8 +1331,8 @@ build.json @home-assistant/supervisor /tests/components/prosegur/ @dgomes /homeassistant/components/proximity/ @mib1185 /tests/components/proximity/ @mib1185 -/homeassistant/components/proxmoxve/ @jhollowe @Corbeno @erwindouna -/tests/components/proxmoxve/ @jhollowe @Corbeno @erwindouna +/homeassistant/components/proxmoxve/ @Corbeno @erwindouna @CoMPaTech +/tests/components/proxmoxve/ @Corbeno @erwindouna @CoMPaTech /homeassistant/components/ps4/ @ktnrg45 /tests/components/ps4/ @ktnrg45 /homeassistant/components/pterodactyl/ @elmurato @@ -1531,8 +1567,8 @@ build.json @home-assistant/supervisor /tests/components/sma/ @kellerza @rklomp @erwindouna /homeassistant/components/smappee/ @bsmappee /tests/components/smappee/ @bsmappee -/homeassistant/components/smarla/ @explicatis @rlint-explicatis -/tests/components/smarla/ @explicatis @rlint-explicatis +/homeassistant/components/smarla/ @explicatis @johannes-exp +/tests/components/smarla/ @explicatis @johannes-exp /homeassistant/components/smart_meter_texas/ @grahamwetzler /tests/components/smart_meter_texas/ @grahamwetzler /homeassistant/components/smartthings/ @joostlek @@ -1586,8 +1622,6 @@ build.json @home-assistant/supervisor /tests/components/srp_energy/ @briglx /homeassistant/components/starline/ @anonym-tsk /tests/components/starline/ @anonym-tsk -/homeassistant/components/starlink/ @boswelja -/tests/components/starlink/ @boswelja /homeassistant/components/statistics/ @ThomDietrich @gjohansson-ST /tests/components/statistics/ @ThomDietrich @gjohansson-ST /homeassistant/components/steam_online/ @tkdrob @@ -1640,6 +1674,8 @@ build.json @home-assistant/supervisor /tests/components/system_bridge/ @timmo001 /homeassistant/components/systemmonitor/ @gjohansson-ST /tests/components/systemmonitor/ @gjohansson-ST +/homeassistant/components/systemnexa2/ @konsulten +/tests/components/systemnexa2/ @konsulten /homeassistant/components/tado/ @erwindouna /tests/components/tado/ @erwindouna /homeassistant/components/tag/ @home-assistant/core @@ -1665,6 +1701,8 @@ build.json @home-assistant/supervisor /tests/components/telegram_bot/ @hanwg /homeassistant/components/tellduslive/ @fredrike /tests/components/tellduslive/ @fredrike +/homeassistant/components/teltonika/ @karlbeecken +/tests/components/teltonika/ @karlbeecken /homeassistant/components/template/ @Petro31 @home-assistant/core /tests/components/template/ @Petro31 @home-assistant/core /homeassistant/components/tesla_fleet/ @Bre77 @@ -1677,7 +1715,6 @@ build.json @home-assistant/supervisor /tests/components/tessie/ @Bre77 /homeassistant/components/text/ @home-assistant/core /tests/components/text/ @home-assistant/core -/homeassistant/components/tfiac/ @fredrike @mellado /homeassistant/components/thermobeacon/ @bdraco /tests/components/thermobeacon/ @bdraco /homeassistant/components/thermopro/ @bdraco @h3ss @@ -1731,12 +1768,16 @@ build.json @home-assistant/supervisor /tests/components/trafikverket_train/ @gjohansson-ST /homeassistant/components/trafikverket_weatherstation/ @gjohansson-ST /tests/components/trafikverket_weatherstation/ @gjohansson-ST +/homeassistant/components/trane/ @bdraco +/tests/components/trane/ @bdraco /homeassistant/components/transmission/ @engrbm87 @JPHutchins @andrew-codechimp /tests/components/transmission/ @engrbm87 @JPHutchins @andrew-codechimp /homeassistant/components/trend/ @jpbede /tests/components/trend/ @jpbede /homeassistant/components/triggercmd/ @rvmey /tests/components/triggercmd/ @rvmey +/homeassistant/components/trmnl/ @joostlek +/tests/components/trmnl/ @joostlek /homeassistant/components/tts/ @home-assistant/core /tests/components/tts/ @home-assistant/core /homeassistant/components/tuya/ @Tuya @zlinoliver @@ -1753,6 +1794,8 @@ build.json @home-assistant/supervisor /tests/components/ukraine_alarm/ @PaulAnnekov /homeassistant/components/unifi/ @Kane610 /tests/components/unifi/ @Kane610 +/homeassistant/components/unifi_access/ @imhotep @RaHehl +/tests/components/unifi_access/ @imhotep @RaHehl /homeassistant/components/unifi_direct/ @tofuSCHNITZEL /homeassistant/components/unifiled/ @florisvdk /homeassistant/components/unifiprotect/ @RaHehl @@ -1792,8 +1835,8 @@ build.json @home-assistant/supervisor /tests/components/vegehub/ @thulrus /homeassistant/components/velbus/ @Cereal2nd @brefra /tests/components/velbus/ @Cereal2nd @brefra -/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio @wollew -/tests/components/velux/ @Julius2342 @DeerMaximum @pawlizio @wollew +/homeassistant/components/velux/ @Julius2342 @pawlizio @wollew +/tests/components/velux/ @Julius2342 @pawlizio @wollew /homeassistant/components/venstar/ @garbled1 @jhollowe /tests/components/venstar/ @garbled1 @jhollowe /homeassistant/components/versasense/ @imstevenxyz @@ -1866,8 +1909,8 @@ build.json @home-assistant/supervisor /tests/components/webostv/ @thecode /homeassistant/components/websocket_api/ @home-assistant/core /tests/components/websocket_api/ @home-assistant/core -/homeassistant/components/weheat/ @jesperraemaekers -/tests/components/weheat/ @jesperraemaekers +/homeassistant/components/weheat/ @barryvdh +/tests/components/weheat/ @barryvdh /homeassistant/components/wemo/ @esev /tests/components/wemo/ @esev /homeassistant/components/whirlpool/ @abmantis @mkmer @@ -1876,15 +1919,19 @@ build.json @home-assistant/supervisor /tests/components/whois/ @frenck /homeassistant/components/wiffi/ @mampfes /tests/components/wiffi/ @mampfes +/homeassistant/components/wiim/ @Linkplay2020 +/tests/components/wiim/ @Linkplay2020 /homeassistant/components/wilight/ @leofig-rj /tests/components/wilight/ @leofig-rj +/homeassistant/components/window/ @home-assistant/core +/tests/components/window/ @home-assistant/core /homeassistant/components/wirelesstag/ @sergeymaysak /homeassistant/components/withings/ @joostlek /tests/components/withings/ @joostlek /homeassistant/components/wiz/ @sbidy @arturpragacz /tests/components/wiz/ @sbidy @arturpragacz -/homeassistant/components/wled/ @frenck -/tests/components/wled/ @frenck +/homeassistant/components/wled/ @frenck @mik-laj +/tests/components/wled/ @frenck @mik-laj /homeassistant/components/wmspro/ @mback2k /tests/components/wmspro/ @mback2k /homeassistant/components/wolflink/ @adamkrol93 @mtielen @@ -1945,11 +1992,14 @@ build.json @home-assistant/supervisor /tests/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES /homeassistant/components/zimi/ @markhannon /tests/components/zimi/ @markhannon +/homeassistant/components/zinvolt/ @joostlek +/tests/components/zinvolt/ @joostlek /homeassistant/components/zodiac/ @JulienTant /tests/components/zodiac/ @JulienTant /homeassistant/components/zone/ @home-assistant/core /tests/components/zone/ @home-assistant/core /homeassistant/components/zoneminder/ @rohankapoorcom @nabbi +/tests/components/zoneminder/ @rohankapoorcom @nabbi /homeassistant/components/zwave_js/ @home-assistant/z-wave /tests/components/zwave_js/ @home-assistant/z-wave /homeassistant/components/zwave_me/ @lawfulchaos @Z-Wave-Me @PoltoS diff --git a/Dockerfile b/Dockerfile index 55df84e84538fa..55919f9fccf6d4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,6 @@ LABEL \ org.opencontainers.image.description="Open-source home automation platform running on Python 3" \ org.opencontainers.image.documentation="https://www.home-assistant.io/docs/" \ org.opencontainers.image.licenses="Apache-2.0" \ - org.opencontainers.image.source="https://github.com/home-assistant/core" \ org.opencontainers.image.title="Home Assistant" \ org.opencontainers.image.url="https://www.home-assistant.io/" @@ -30,7 +29,7 @@ RUN \ # Verify go2rtc can be executed go2rtc --version \ # Install uv - && pip3 install uv==0.9.26 + && pip3 install uv==0.10.6 WORKDIR /usr/src diff --git a/codecov.yml b/codecov.yml index 9cb9084ed61f67..d4bd8b7fcb721c 100644 --- a/codecov.yml +++ b/codecov.yml @@ -10,6 +10,7 @@ coverage: target: auto threshold: 1 paths: + - homeassistant/components/*/backup.py - homeassistant/components/*/config_flow.py - homeassistant/components/*/device_action.py - homeassistant/components/*/device_condition.py @@ -28,6 +29,7 @@ coverage: target: 100 threshold: 0 paths: + - homeassistant/components/*/backup.py - homeassistant/components/*/config_flow.py - homeassistant/components/*/device_action.py - homeassistant/components/*/device_condition.py diff --git a/homeassistant/backup_restore.py b/homeassistant/backup_restore.py index 4d309469017a2f..6800851c182cbd 100644 --- a/homeassistant/backup_restore.py +++ b/homeassistant/backup_restore.py @@ -4,7 +4,6 @@ from collections.abc import Iterable from dataclasses import dataclass -import hashlib import json import logging from pathlib import Path @@ -40,17 +39,6 @@ class RestoreBackupFileContent: restore_homeassistant: bool -def password_to_key(password: str) -> bytes: - """Generate a AES Key from password. - - Matches the implementation in supervisor.backups.utils.password_to_key. - """ - key: bytes = password.encode() - for _ in range(100): - key = hashlib.sha256(key).digest() - return key[:16] - - def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent | None: """Return the contents of the restore backup file.""" instruction_path = config_dir.joinpath(RESTORE_BACKUP_FILE) @@ -96,15 +84,14 @@ def _extract_backup( """Extract the backup file to the config directory.""" with ( TemporaryDirectory() as tempdir, - securetar.SecureTarFile( + securetar.SecureTarArchive( restore_content.backup_file_path, - gzip=False, mode="r", ) as ostf, ): - ostf.extractall( + ostf.tar.extractall( path=Path(tempdir, "extracted"), - members=securetar.secure_path(ostf), + members=securetar.secure_path(ostf.tar), filter="fully_trusted", ) backup_meta_file = Path(tempdir, "extracted", "backup.json") @@ -126,10 +113,7 @@ def _extract_backup( f"homeassistant.tar{'.gz' if backup_meta['compressed'] else ''}", ), gzip=backup_meta["compressed"], - key=password_to_key(restore_content.password) - if restore_content.password is not None - else None, - mode="r", + password=restore_content.password, ) as istf: istf.extractall( path=Path(tempdir, "homeassistant"), diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index c7347780b9ea28..8590bc8fdfda4c 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -70,7 +70,7 @@ SIGNAL_BOOTSTRAP_INTEGRATIONS, ) from .core_config import async_process_ha_core_config -from .exceptions import HomeAssistantError +from .exceptions import HomeAssistantError, UnsupportedStorageVersionError from .helpers import ( area_registry, category_registry, @@ -210,6 +210,7 @@ "analytics", # Needed for onboarding "application_credentials", "backup", + "brands", "frontend", "hardware", "labs", @@ -235,9 +236,23 @@ "input_text", "schedule", "timer", + # + # Base platforms: + *BASE_PLATFORMS, + # + # Integrations providing triggers and conditions for base platforms: + "door", + "garage_door", + "gate", + "humidity", + "motion", + "occupancy", + "window", } DEFAULT_INTEGRATIONS_RECOVERY_MODE = { # These integrations are set up if recovery mode is activated. + "backup", + "cloud", "frontend", } DEFAULT_INTEGRATIONS_SUPERVISOR = { @@ -432,32 +447,56 @@ def _init_blocking_io_modules_in_executor() -> None: is_docker_env() -async def async_load_base_functionality(hass: core.HomeAssistant) -> None: - """Load the registries and modules that will do blocking I/O.""" +async def async_load_base_functionality(hass: core.HomeAssistant) -> bool: + """Load the registries and modules that will do blocking I/O. + + Return whether loading succeeded. + """ if DATA_REGISTRIES_LOADED in hass.data: - return + return True + hass.data[DATA_REGISTRIES_LOADED] = None entity.async_setup(hass) frame.async_setup(hass) template.async_setup(hass) translation.async_setup(hass) - await asyncio.gather( - create_eager_task(get_internal_store_manager(hass).async_initialize()), - create_eager_task(area_registry.async_load(hass)), - create_eager_task(category_registry.async_load(hass)), - create_eager_task(device_registry.async_load(hass)), - create_eager_task(entity_registry.async_load(hass)), - create_eager_task(floor_registry.async_load(hass)), - create_eager_task(issue_registry.async_load(hass)), - create_eager_task(label_registry.async_load(hass)), - hass.async_add_executor_job(_init_blocking_io_modules_in_executor), - create_eager_task(template.async_load_custom_templates(hass)), - create_eager_task(restore_state.async_load(hass)), - create_eager_task(hass.config_entries.async_initialize()), - create_eager_task(async_get_system_info(hass)), - create_eager_task(condition.async_setup(hass)), - create_eager_task(trigger.async_setup(hass)), - ) + + recovery = hass.config.recovery_mode + try: + await asyncio.gather( + create_eager_task(get_internal_store_manager(hass).async_initialize()), + create_eager_task(area_registry.async_load(hass, load_empty=recovery)), + create_eager_task(category_registry.async_load(hass, load_empty=recovery)), + create_eager_task(device_registry.async_load(hass, load_empty=recovery)), + create_eager_task(entity_registry.async_load(hass, load_empty=recovery)), + create_eager_task(floor_registry.async_load(hass, load_empty=recovery)), + create_eager_task(issue_registry.async_load(hass, load_empty=recovery)), + create_eager_task(label_registry.async_load(hass, load_empty=recovery)), + hass.async_add_executor_job(_init_blocking_io_modules_in_executor), + create_eager_task(template.async_load_custom_templates(hass)), + create_eager_task(restore_state.async_load(hass, load_empty=recovery)), + create_eager_task(hass.config_entries.async_initialize()), + create_eager_task(async_get_system_info(hass)), + create_eager_task(condition.async_setup(hass)), + create_eager_task(trigger.async_setup(hass)), + ) + except UnsupportedStorageVersionError as err: + # If we're already in recovery mode, we don't want to handle the exception + # and activate recovery mode again, as that would lead to an infinite loop. + if recovery: + raise + + _LOGGER.error( + "Storage file %s was created by a newer version of Home Assistant" + " (storage version %s > %s); activating recovery mode; on-disk data" + " is preserved; upgrade Home Assistant or restore from a backup", + err.storage_key, + err.found_version, + err.max_supported_version, + ) + return False + + return True async def async_from_config_dict( @@ -474,7 +513,9 @@ async def async_from_config_dict( # Prime custom component cache early so we know if registry entries are tied # to a custom integration await loader.async_get_custom_components(hass) - await async_load_base_functionality(hass) + + if not await async_load_base_functionality(hass): + return None # Set up core. _LOGGER.debug("Setting up %s", CORE_INTEGRATIONS) diff --git a/homeassistant/brands/american_standard.json b/homeassistant/brands/american_standard.json new file mode 100644 index 00000000000000..c500f8921a8c39 --- /dev/null +++ b/homeassistant/brands/american_standard.json @@ -0,0 +1,5 @@ +{ + "domain": "american_standard", + "name": "American Standard", + "integrations": ["nexia", "trane"] +} diff --git a/homeassistant/brands/powerfox.json b/homeassistant/brands/powerfox.json new file mode 100644 index 00000000000000..7b3601f7db47e1 --- /dev/null +++ b/homeassistant/brands/powerfox.json @@ -0,0 +1,5 @@ +{ + "domain": "powerfox", + "name": "Powerfox", + "integrations": ["powerfox", "powerfox_local"] +} diff --git a/homeassistant/brands/trane.json b/homeassistant/brands/trane.json new file mode 100644 index 00000000000000..aa4592a8aa2c54 --- /dev/null +++ b/homeassistant/brands/trane.json @@ -0,0 +1,5 @@ +{ + "domain": "trane", + "name": "Trane", + "integrations": ["nexia", "trane"] +} diff --git a/homeassistant/brands/ubiquiti.json b/homeassistant/brands/ubiquiti.json index bb345775a60491..bcc6349532420c 100644 --- a/homeassistant/brands/ubiquiti.json +++ b/homeassistant/brands/ubiquiti.json @@ -1,5 +1,12 @@ { "domain": "ubiquiti", "name": "Ubiquiti", - "integrations": ["airos", "unifi", "unifi_direct", "unifiled", "unifiprotect"] + "integrations": [ + "airos", + "unifi", + "unifi_access", + "unifi_direct", + "unifiled", + "unifiprotect" + ] } diff --git a/homeassistant/brands/ubisys.json b/homeassistant/brands/ubisys.json new file mode 100644 index 00000000000000..bae2b2afdfe5fe --- /dev/null +++ b/homeassistant/brands/ubisys.json @@ -0,0 +1,5 @@ +{ + "domain": "ubisys", + "name": "Ubisys", + "iot_standards": ["zigbee"] +} diff --git a/homeassistant/components/abode/services.py b/homeassistant/components/abode/services.py index c4f8b7fe1f6432..5b2a05f52287b2 100644 --- a/homeassistant/components/abode/services.py +++ b/homeassistant/components/abode/services.py @@ -12,10 +12,6 @@ from .const import DOMAIN, DOMAIN_DATA, LOGGER -SERVICE_SETTINGS = "change_setting" -SERVICE_CAPTURE_IMAGE = "capture_image" -SERVICE_TRIGGER_AUTOMATION = "trigger_automation" - ATTR_SETTING = "setting" ATTR_VALUE = "value" @@ -75,16 +71,13 @@ def async_setup_services(hass: HomeAssistant) -> None: """Home Assistant services.""" hass.services.async_register( - DOMAIN, SERVICE_SETTINGS, _change_setting, schema=CHANGE_SETTING_SCHEMA + DOMAIN, "change_setting", _change_setting, schema=CHANGE_SETTING_SCHEMA ) hass.services.async_register( - DOMAIN, SERVICE_CAPTURE_IMAGE, _capture_image, schema=CAPTURE_IMAGE_SCHEMA + DOMAIN, "capture_image", _capture_image, schema=CAPTURE_IMAGE_SCHEMA ) hass.services.async_register( - DOMAIN, - SERVICE_TRIGGER_AUTOMATION, - _trigger_automation, - schema=AUTOMATION_SCHEMA, + DOMAIN, "trigger_automation", _trigger_automation, schema=AUTOMATION_SCHEMA ) diff --git a/homeassistant/components/accuweather/__init__.py b/homeassistant/components/accuweather/__init__.py index bb453c67f57f82..de8f2ab93a9bb6 100644 --- a/homeassistant/components/accuweather/__init__.py +++ b/homeassistant/components/accuweather/__init__.py @@ -7,7 +7,7 @@ from accuweather import AccuWeather -from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -72,7 +72,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AccuWeatherConfigEntry) ent_reg = er.async_get(hass) for day in range(5): unique_id = f"{location_key}-ozone-{day}" - if entity_id := ent_reg.async_get_entity_id(SENSOR_PLATFORM, DOMAIN, unique_id): + if entity_id := ent_reg.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, unique_id): _LOGGER.debug("Removing ozone sensor entity %s", entity_id) ent_reg.async_remove(entity_id) diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index 07faa0cf26a1f2..79c3baeccbfb23 100644 --- a/homeassistant/components/accuweather/manifest.json +++ b/homeassistant/components/accuweather/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["accuweather"], - "requirements": ["accuweather==5.0.0"] + "requirements": ["accuweather==5.1.0"] } diff --git a/homeassistant/components/accuweather/system_health.py b/homeassistant/components/accuweather/system_health.py index f5efaf3079fdae..99335a9dd8f9cd 100644 --- a/homeassistant/components/accuweather/system_health.py +++ b/homeassistant/components/accuweather/system_health.py @@ -30,6 +30,8 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: ) return { - "can_reach_server": system_health.async_check_can_reach_url(hass, ENDPOINT), + "can_reach_server": system_health.async_check_can_reach_url( + hass, str(ENDPOINT) + ), "remaining_requests": remaining_requests, } diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py index 25d6297cee686b..dd6b3f4b0a4f2b 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -191,7 +191,7 @@ def _async_forecast_daily(self) -> list[Forecast] | None: { ATTR_FORECAST_TIME: utc_from_timestamp(item["EpochDate"]).isoformat(), ATTR_FORECAST_CLOUD_COVERAGE: item["CloudCoverDay"], - ATTR_FORECAST_HUMIDITY: item["RelativeHumidityDay"]["Average"], + ATTR_FORECAST_HUMIDITY: item["RelativeHumidityDay"].get("Average"), ATTR_FORECAST_NATIVE_TEMP: item["TemperatureMax"][ATTR_VALUE], ATTR_FORECAST_NATIVE_TEMP_LOW: item["TemperatureMin"][ATTR_VALUE], ATTR_FORECAST_NATIVE_APPARENT_TEMP: item["RealFeelTemperatureMax"][ diff --git a/homeassistant/components/actron_air/config_flow.py b/homeassistant/components/actron_air/config_flow.py index d882424ef018c5..3faefe7590fa47 100644 --- a/homeassistant/components/actron_air/config_flow.py +++ b/homeassistant/components/actron_air/config_flow.py @@ -120,7 +120,7 @@ async def async_step_timeout( return self.async_show_form( step_id="timeout", ) - del self.login_task + self.login_task = None return await self.async_step_user() async def async_step_reauth( diff --git a/homeassistant/components/actron_air/manifest.json b/homeassistant/components/actron_air/manifest.json index 6fe0f14bb247d1..724ff101cb96ee 100644 --- a/homeassistant/components/actron_air/manifest.json +++ b/homeassistant/components/actron_air/manifest.json @@ -12,6 +12,6 @@ "documentation": "https://www.home-assistant.io/integrations/actron_air", "integration_type": "hub", "iot_class": "cloud_polling", - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["actron-neo-api==0.4.1"] } diff --git a/homeassistant/components/actron_air/quality_scale.yaml b/homeassistant/components/actron_air/quality_scale.yaml index cb608240459798..240b3e4b185175 100644 --- a/homeassistant/components/actron_air/quality_scale.yaml +++ b/homeassistant/components/actron_air/quality_scale.yaml @@ -37,7 +37,7 @@ rules: log-when-unavailable: done parallel-updates: done reauthentication-flow: done - test-coverage: todo + test-coverage: done # Gold devices: done diff --git a/homeassistant/components/adax/climate.py b/homeassistant/components/adax/climate.py old mode 100644 new mode 100755 index b41a443243779c..62ddb213e2a6bd --- a/homeassistant/components/adax/climate.py +++ b/homeassistant/components/adax/climate.py @@ -168,29 +168,57 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: if hvac_mode == HVACMode.HEAT: temperature = self._attr_target_temperature or self._attr_min_temp await self._adax_data_handler.set_target_temperature(temperature) + self._attr_target_temperature = temperature + self._attr_icon = "mdi:radiator" elif hvac_mode == HVACMode.OFF: await self._adax_data_handler.set_target_temperature(0) + self._attr_icon = "mdi:radiator-off" + else: + # Ignore unsupported HVAC modes to avoid desynchronizing entity state + # from the physical device. + return + + self._attr_hvac_mode = hvac_mode + self.async_write_ha_state() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: return - await self._adax_data_handler.set_target_temperature(temperature) + if self._attr_hvac_mode == HVACMode.HEAT: + await self._adax_data_handler.set_target_temperature(temperature) - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + self._attr_target_temperature = temperature + self.async_write_ha_state() + + def _update_hvac_attributes(self) -> None: + """Update hvac mode and temperatures from coordinator data. + + The coordinator reports a target temperature of 0 when the heater is + turned off. In that case, only the hvac mode and icon are updated and + the previous non-zero target temperature is preserved. When the + reported target temperature is non-zero, the stored target temperature + is updated to match the coordinator value. + """ if data := self.coordinator.data: self._attr_current_temperature = data["current_temperature"] - self._attr_available = self._attr_current_temperature is not None if (target_temp := data["target_temperature"]) == 0: self._attr_hvac_mode = HVACMode.OFF self._attr_icon = "mdi:radiator-off" - if target_temp == 0: + if self._attr_target_temperature is None: self._attr_target_temperature = self._attr_min_temp else: self._attr_hvac_mode = HVACMode.HEAT self._attr_icon = "mdi:radiator" self._attr_target_temperature = target_temp + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_hvac_attributes() super()._handle_coordinator_update() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._update_hvac_attributes() diff --git a/homeassistant/components/ads/light.py b/homeassistant/components/ads/light.py index 3de223e5fc44a4..63d699a00554c5 100644 --- a/homeassistant/components/ads/light.py +++ b/homeassistant/components/ads/light.py @@ -9,9 +9,13 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + DEFAULT_MAX_KELVIN, + DEFAULT_MIN_KELVIN, PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA, ColorMode, LightEntity, + filter_supported_color_modes, ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant @@ -24,13 +28,20 @@ from .hub import AdsHub CONF_ADS_VAR_BRIGHTNESS = "adsvar_brightness" +CONF_ADS_VAR_COLOR_TEMP_KELVIN = "adsvar_color_temp_kelvin" +CONF_MIN_COLOR_TEMP_KELVIN = "min_color_temp_kelvin" +CONF_MAX_COLOR_TEMP_KELVIN = "max_color_temp_kelvin" STATE_KEY_BRIGHTNESS = "brightness" +STATE_KEY_COLOR_TEMP_KELVIN = "color_temp_kelvin" DEFAULT_NAME = "ADS Light" PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA.extend( { vol.Required(CONF_ADS_VAR): cv.string, vol.Optional(CONF_ADS_VAR_BRIGHTNESS): cv.string, + vol.Optional(CONF_ADS_VAR_COLOR_TEMP_KELVIN): cv.string, + vol.Optional(CONF_MIN_COLOR_TEMP_KELVIN): cv.positive_int, + vol.Optional(CONF_MAX_COLOR_TEMP_KELVIN): cv.positive_int, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, } ) @@ -47,9 +58,24 @@ def setup_platform( ads_var_enable: str = config[CONF_ADS_VAR] ads_var_brightness: str | None = config.get(CONF_ADS_VAR_BRIGHTNESS) + ads_var_color_temp_kelvin: str | None = config.get(CONF_ADS_VAR_COLOR_TEMP_KELVIN) + min_color_temp_kelvin: int | None = config.get(CONF_MIN_COLOR_TEMP_KELVIN) + max_color_temp_kelvin: int | None = config.get(CONF_MAX_COLOR_TEMP_KELVIN) name: str = config[CONF_NAME] - add_entities([AdsLight(ads_hub, ads_var_enable, ads_var_brightness, name)]) + add_entities( + [ + AdsLight( + ads_hub, + ads_var_enable, + ads_var_brightness, + ads_var_color_temp_kelvin, + min_color_temp_kelvin, + max_color_temp_kelvin, + name, + ) + ] + ) class AdsLight(AdsEntity, LightEntity): @@ -60,18 +86,40 @@ def __init__( ads_hub: AdsHub, ads_var_enable: str, ads_var_brightness: str | None, + ads_var_color_temp_kelvin: str | None, + min_color_temp_kelvin: int | None, + max_color_temp_kelvin: int | None, name: str, ) -> None: """Initialize AdsLight entity.""" super().__init__(ads_hub, name, ads_var_enable) self._state_dict[STATE_KEY_BRIGHTNESS] = None + self._state_dict[STATE_KEY_COLOR_TEMP_KELVIN] = None self._ads_var_brightness = ads_var_brightness + self._ads_var_color_temp_kelvin = ads_var_color_temp_kelvin + + # Determine supported color modes + color_modes = {ColorMode.ONOFF} if ads_var_brightness is not None: - self._attr_color_mode = ColorMode.BRIGHTNESS - self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} - else: - self._attr_color_mode = ColorMode.ONOFF - self._attr_supported_color_modes = {ColorMode.ONOFF} + color_modes.add(ColorMode.BRIGHTNESS) + if ads_var_color_temp_kelvin is not None: + color_modes.add(ColorMode.COLOR_TEMP) + + self._attr_supported_color_modes = filter_supported_color_modes(color_modes) + self._attr_color_mode = next(iter(self._attr_supported_color_modes)) + + # Set color temperature range (static config values take precedence over defaults) + if ads_var_color_temp_kelvin is not None: + self._attr_min_color_temp_kelvin = ( + min_color_temp_kelvin + if min_color_temp_kelvin is not None + else DEFAULT_MIN_KELVIN + ) + self._attr_max_color_temp_kelvin = ( + max_color_temp_kelvin + if max_color_temp_kelvin is not None + else DEFAULT_MAX_KELVIN + ) async def async_added_to_hass(self) -> None: """Register device notification.""" @@ -84,11 +132,23 @@ async def async_added_to_hass(self) -> None: STATE_KEY_BRIGHTNESS, ) + if self._ads_var_color_temp_kelvin is not None: + await self.async_initialize_device( + self._ads_var_color_temp_kelvin, + pyads.PLCTYPE_UINT, + STATE_KEY_COLOR_TEMP_KELVIN, + ) + @property def brightness(self) -> int | None: """Return the brightness of the light (0..255).""" return self._state_dict[STATE_KEY_BRIGHTNESS] + @property + def color_temp_kelvin(self) -> int | None: + """Return the color temperature in Kelvin.""" + return self._state_dict[STATE_KEY_COLOR_TEMP_KELVIN] + @property def is_on(self) -> bool: """Return True if the entity is on.""" @@ -97,6 +157,8 @@ def is_on(self) -> bool: def turn_on(self, **kwargs: Any) -> None: """Turn the light on or set a specific dimmer value.""" brightness = kwargs.get(ATTR_BRIGHTNESS) + color_temp = kwargs.get(ATTR_COLOR_TEMP_KELVIN) + self._ads_hub.write_by_name(self._ads_var, True, pyads.PLCTYPE_BOOL) if self._ads_var_brightness is not None and brightness is not None: @@ -104,6 +166,11 @@ def turn_on(self, **kwargs: Any) -> None: self._ads_var_brightness, brightness, pyads.PLCTYPE_UINT ) + if self._ads_var_color_temp_kelvin is not None and color_temp is not None: + self._ads_hub.write_by_name( + self._ads_var_color_temp_kelvin, color_temp, pyads.PLCTYPE_UINT + ) + def turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" self._ads_hub.write_by_name(self._ads_var, False, pyads.PLCTYPE_BOOL) diff --git a/homeassistant/components/advantage_air/__init__.py b/homeassistant/components/advantage_air/__init__.py index c787990f188814..4114f612fe9590 100644 --- a/homeassistant/components/advantage_air/__init__.py +++ b/homeassistant/components/advantage_air/__init__.py @@ -1,26 +1,17 @@ """Advantage Air climate integration.""" -from datetime import timedelta -import logging +from advantage_air import advantage_air -from advantage_air import ApiError, advantage_air - -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, 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.debounce import Debouncer from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ADVANTAGE_AIR_RETRY, DOMAIN -from .models import AdvantageAirData +from .coordinator import AdvantageAirCoordinator, AdvantageAirDataConfigEntry from .services import async_setup_services -type AdvantageAirDataConfigEntry = ConfigEntry[AdvantageAirData] - -ADVANTAGE_AIR_SYNC_INTERVAL = 15 PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, @@ -32,9 +23,6 @@ Platform.UPDATE, ] -_LOGGER = logging.getLogger(__name__) -REQUEST_REFRESH_DELAY = 0.5 - CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -57,27 +45,10 @@ async def async_setup_entry( retry=ADVANTAGE_AIR_RETRY, ) - async def async_get(): - try: - return await api.async_get() - except ApiError as err: - raise UpdateFailed(err) from err - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name="Advantage Air", - update_method=async_get, - update_interval=timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL), - request_refresh_debouncer=Debouncer( - hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False - ), - ) - + coordinator = AdvantageAirCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() - entry.runtime_data = AdvantageAirData(coordinator, api) + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/advantage_air/binary_sensor.py b/homeassistant/components/advantage_air/binary_sensor.py index dd306b82c8ae74..28fdaa9b7e1cf5 100644 --- a/homeassistant/components/advantage_air/binary_sensor.py +++ b/homeassistant/components/advantage_air/binary_sensor.py @@ -11,8 +11,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AdvantageAirDataConfigEntry +from .coordinator import AdvantageAirCoordinator from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity -from .models import AdvantageAirData PARALLEL_UPDATES = 0 @@ -24,19 +24,23 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir Binary Sensor platform.""" - instance = config_entry.runtime_data + coordinator = config_entry.runtime_data entities: list[BinarySensorEntity] = [] - if aircons := instance.coordinator.data.get("aircons"): + if aircons := coordinator.data.get("aircons"): for ac_key, ac_device in aircons.items(): - entities.append(AdvantageAirFilter(instance, ac_key)) + entities.append(AdvantageAirFilter(coordinator, ac_key)) for zone_key, zone in ac_device["zones"].items(): # Only add motion sensor when motion is enabled if zone["motionConfig"] >= 2: - entities.append(AdvantageAirZoneMotion(instance, ac_key, zone_key)) + entities.append( + AdvantageAirZoneMotion(coordinator, ac_key, zone_key) + ) # Only add MyZone if it is available if zone["type"] != 0: - entities.append(AdvantageAirZoneMyZone(instance, ac_key, zone_key)) + entities.append( + AdvantageAirZoneMyZone(coordinator, ac_key, zone_key) + ) async_add_entities(entities) @@ -47,9 +51,9 @@ class AdvantageAirFilter(AdvantageAirAcEntity, BinarySensorEntity): _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_name = "Filter" - def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: + def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None: """Initialize an Advantage Air Filter sensor.""" - super().__init__(instance, ac_key) + super().__init__(coordinator, ac_key) self._attr_unique_id += "-filter" @property @@ -63,9 +67,11 @@ class AdvantageAirZoneMotion(AdvantageAirZoneEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.MOTION - def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: + def __init__( + self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str + ) -> None: """Initialize an Advantage Air Zone Motion sensor.""" - super().__init__(instance, ac_key, zone_key) + super().__init__(coordinator, ac_key, zone_key) self._attr_name = f"{self._zone['name']} motion" self._attr_unique_id += "-motion" @@ -81,9 +87,11 @@ class AdvantageAirZoneMyZone(AdvantageAirZoneEntity, BinarySensorEntity): _attr_entity_registry_enabled_default = False _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: + def __init__( + self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str + ) -> None: """Initialize an Advantage Air Zone MyZone sensor.""" - super().__init__(instance, ac_key, zone_key) + super().__init__(coordinator, ac_key, zone_key) self._attr_name = f"{self._zone['name']} myZone" self._attr_unique_id += "-myzone" diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index 1d593c5c3c853a..938bcb469a6247 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -31,8 +31,8 @@ ADVANTAGE_AIR_STATE_ON, ADVANTAGE_AIR_STATE_OPEN, ) +from .coordinator import AdvantageAirCoordinator from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity -from .models import AdvantageAirData ADVANTAGE_AIR_HVAC_MODES = { "heat": HVACMode.HEAT, @@ -90,16 +90,16 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir climate platform.""" - instance = config_entry.runtime_data + coordinator = config_entry.runtime_data entities: list[ClimateEntity] = [] - if aircons := instance.coordinator.data.get("aircons"): + if aircons := coordinator.data.get("aircons"): for ac_key, ac_device in aircons.items(): - entities.append(AdvantageAirAC(instance, ac_key)) + entities.append(AdvantageAirAC(coordinator, ac_key)) for zone_key, zone in ac_device["zones"].items(): # Only add zone climate control when zone is in temperature control if zone["type"] > 0: - entities.append(AdvantageAirZone(instance, ac_key, zone_key)) + entities.append(AdvantageAirZone(coordinator, ac_key, zone_key)) async_add_entities(entities) @@ -114,9 +114,9 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): _attr_name = None _support_preset = ClimateEntityFeature(0) - def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: + def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None: """Initialize an AdvantageAir AC unit.""" - super().__init__(instance, ac_key) + super().__init__(coordinator, ac_key) self._attr_preset_modes = [ADVANTAGE_AIR_MYZONE] @@ -282,9 +282,11 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity): _attr_max_temp = 32 _attr_min_temp = 16 - def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: + def __init__( + self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str + ) -> None: """Initialize an AdvantageAir Zone control.""" - super().__init__(instance, ac_key, zone_key) + super().__init__(coordinator, ac_key, zone_key) self._attr_name = self._zone["name"] @property diff --git a/homeassistant/components/advantage_air/coordinator.py b/homeassistant/components/advantage_air/coordinator.py new file mode 100644 index 00000000000000..54628d4f4c38a2 --- /dev/null +++ b/homeassistant/components/advantage_air/coordinator.py @@ -0,0 +1,59 @@ +"""Coordinator for the Advantage Air integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any + +from advantage_air import ApiError, advantage_air + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +ADVANTAGE_AIR_SYNC_INTERVAL = 15 +REQUEST_REFRESH_DELAY = 0.5 + +_LOGGER = logging.getLogger(__name__) + +type AdvantageAirDataConfigEntry = ConfigEntry[AdvantageAirCoordinator] + + +class AdvantageAirCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Advantage Air coordinator.""" + + config_entry: AdvantageAirDataConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: AdvantageAirDataConfigEntry, + api: advantage_air, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name="Advantage Air", + update_interval=timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL), + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False + ), + ) + self.api = api + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from the API.""" + try: + return await self.api.async_get() + except ApiError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"error": str(err)}, + ) from err diff --git a/homeassistant/components/advantage_air/cover.py b/homeassistant/components/advantage_air/cover.py index e764d484128a7f..50e9b7b59dee4f 100644 --- a/homeassistant/components/advantage_air/cover.py +++ b/homeassistant/components/advantage_air/cover.py @@ -13,8 +13,8 @@ from . import AdvantageAirDataConfigEntry from .const import ADVANTAGE_AIR_STATE_CLOSE, ADVANTAGE_AIR_STATE_OPEN +from .coordinator import AdvantageAirCoordinator from .entity import AdvantageAirThingEntity, AdvantageAirZoneEntity -from .models import AdvantageAirData PARALLEL_UPDATES = 0 @@ -26,24 +26,24 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir cover platform.""" - instance = config_entry.runtime_data + coordinator = config_entry.runtime_data entities: list[CoverEntity] = [] - if aircons := instance.coordinator.data.get("aircons"): + if aircons := coordinator.data.get("aircons"): for ac_key, ac_device in aircons.items(): for zone_key, zone in ac_device["zones"].items(): # Only add zone vent controls when zone in vent control mode. if zone["type"] == 0: - entities.append(AdvantageAirZoneVent(instance, ac_key, zone_key)) - if things := instance.coordinator.data.get("myThings"): + entities.append(AdvantageAirZoneVent(coordinator, ac_key, zone_key)) + if things := coordinator.data.get("myThings"): for thing in things["things"].values(): if thing["channelDipState"] in [1, 2]: # 1 = "Blind", 2 = "Blind 2" entities.append( - AdvantageAirThingCover(instance, thing, CoverDeviceClass.BLIND) + AdvantageAirThingCover(coordinator, thing, CoverDeviceClass.BLIND) ) elif thing["channelDipState"] in [3, 10]: # 3 & 10 = "Garage door" entities.append( - AdvantageAirThingCover(instance, thing, CoverDeviceClass.GARAGE) + AdvantageAirThingCover(coordinator, thing, CoverDeviceClass.GARAGE) ) async_add_entities(entities) @@ -58,9 +58,11 @@ class AdvantageAirZoneVent(AdvantageAirZoneEntity, CoverEntity): | CoverEntityFeature.SET_POSITION ) - def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: + def __init__( + self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str + ) -> None: """Initialize an Advantage Air Zone Vent.""" - super().__init__(instance, ac_key, zone_key) + super().__init__(coordinator, ac_key, zone_key) self._attr_name = self._zone["name"] @property @@ -106,12 +108,12 @@ class AdvantageAirThingCover(AdvantageAirThingEntity, CoverEntity): def __init__( self, - instance: AdvantageAirData, + coordinator: AdvantageAirCoordinator, thing: dict[str, Any], device_class: CoverDeviceClass, ) -> None: """Initialize an Advantage Air Things Cover.""" - super().__init__(instance, thing) + super().__init__(coordinator, thing) self._attr_device_class = device_class @property diff --git a/homeassistant/components/advantage_air/diagnostics.py b/homeassistant/components/advantage_air/diagnostics.py index 8d998d1ee90c5c..d15ce57df5ebc2 100644 --- a/homeassistant/components/advantage_air/diagnostics.py +++ b/homeassistant/components/advantage_air/diagnostics.py @@ -27,7 +27,7 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: AdvantageAirDataConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - data = config_entry.runtime_data.coordinator.data + data = config_entry.runtime_data.data # Return only the relevant children return { diff --git a/homeassistant/components/advantage_air/entity.py b/homeassistant/components/advantage_air/entity.py index be2135e4767b09..c0f4cd5512c241 100644 --- a/homeassistant/components/advantage_air/entity.py +++ b/homeassistant/components/advantage_air/entity.py @@ -9,17 +9,17 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .models import AdvantageAirData +from .coordinator import AdvantageAirCoordinator -class AdvantageAirEntity(CoordinatorEntity): +class AdvantageAirEntity(CoordinatorEntity[AdvantageAirCoordinator]): """Parent class for Advantage Air Entities.""" _attr_has_entity_name = True - def __init__(self, instance: AdvantageAirData) -> None: + def __init__(self, coordinator: AdvantageAirCoordinator) -> None: """Initialize common aspects of an Advantage Air entity.""" - super().__init__(instance.coordinator) + super().__init__(coordinator) self._attr_unique_id: str = self.coordinator.data["system"]["rid"] def update_handle_factory(self, func, *keys): @@ -41,9 +41,9 @@ async def update_handle(*values): class AdvantageAirAcEntity(AdvantageAirEntity): """Parent class for Advantage Air AC Entities.""" - def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: + def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None: """Initialize common aspects of an Advantage Air ac entity.""" - super().__init__(instance) + super().__init__(coordinator) self.ac_key: str = ac_key self._attr_unique_id += f"-{ac_key}" @@ -56,7 +56,7 @@ def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: name=self.coordinator.data["aircons"][self.ac_key]["info"]["name"], ) self.async_update_ac = self.update_handle_factory( - instance.api.aircon.async_update_ac, self.ac_key + coordinator.api.aircon.async_update_ac, self.ac_key ) @property @@ -73,14 +73,16 @@ def _myzone(self) -> dict[str, Any] | None: class AdvantageAirZoneEntity(AdvantageAirAcEntity): """Parent class for Advantage Air Zone Entities.""" - def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: + def __init__( + self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str + ) -> None: """Initialize common aspects of an Advantage Air zone entity.""" - super().__init__(instance, ac_key) + super().__init__(coordinator, ac_key) self.zone_key: str = zone_key self._attr_unique_id += f"-{zone_key}" self.async_update_zone = self.update_handle_factory( - instance.api.aircon.async_update_zone, self.ac_key, self.zone_key + coordinator.api.aircon.async_update_zone, self.ac_key, self.zone_key ) @property @@ -93,9 +95,11 @@ class AdvantageAirThingEntity(AdvantageAirEntity): _attr_name = None - def __init__(self, instance: AdvantageAirData, thing: dict[str, Any]) -> None: + def __init__( + self, coordinator: AdvantageAirCoordinator, thing: dict[str, Any] + ) -> None: """Initialize common aspects of an Advantage Air Things entity.""" - super().__init__(instance) + super().__init__(coordinator) self._id = thing["id"] self._attr_unique_id += f"-{self._id}" @@ -108,7 +112,7 @@ def __init__(self, instance: AdvantageAirData, thing: dict[str, Any]) -> None: name=thing["name"], ) self.async_update_value = self.update_handle_factory( - instance.api.things.async_update_value, self._id + coordinator.api.things.async_update_value, self._id ) @property @@ -117,7 +121,7 @@ def _data(self) -> dict: return self.coordinator.data["myThings"]["things"][self._id] @property - def is_on(self): + def is_on(self) -> bool: """Return if the thing is considered on.""" return self._data["value"] > 0 diff --git a/homeassistant/components/advantage_air/light.py b/homeassistant/components/advantage_air/light.py index 9708adbc1f7315..6ca26e973f0ae3 100644 --- a/homeassistant/components/advantage_air/light.py +++ b/homeassistant/components/advantage_air/light.py @@ -9,8 +9,8 @@ from . import AdvantageAirDataConfigEntry from .const import ADVANTAGE_AIR_STATE_ON, DOMAIN +from .coordinator import AdvantageAirCoordinator from .entity import AdvantageAirEntity, AdvantageAirThingEntity -from .models import AdvantageAirData async def async_setup_entry( @@ -20,21 +20,21 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir light platform.""" - instance = config_entry.runtime_data + coordinator = config_entry.runtime_data entities: list[LightEntity] = [] - if my_lights := instance.coordinator.data.get("myLights"): + if my_lights := coordinator.data.get("myLights"): for light in my_lights["lights"].values(): if light.get("relay"): - entities.append(AdvantageAirLight(instance, light)) + entities.append(AdvantageAirLight(coordinator, light)) else: - entities.append(AdvantageAirLightDimmable(instance, light)) - if things := instance.coordinator.data.get("myThings"): + entities.append(AdvantageAirLightDimmable(coordinator, light)) + if things := coordinator.data.get("myThings"): for thing in things["things"].values(): if thing["channelDipState"] == 4: # 4 = "Light (on/off)"" - entities.append(AdvantageAirThingLight(instance, thing)) + entities.append(AdvantageAirThingLight(coordinator, thing)) elif thing["channelDipState"] == 5: # 5 = "Light (Dimmable)"" - entities.append(AdvantageAirThingLightDimmable(instance, thing)) + entities.append(AdvantageAirThingLightDimmable(coordinator, thing)) async_add_entities(entities) @@ -45,9 +45,11 @@ class AdvantageAirLight(AdvantageAirEntity, LightEntity): _attr_supported_color_modes = {ColorMode.ONOFF} _attr_name = None - def __init__(self, instance: AdvantageAirData, light: dict[str, Any]) -> None: + def __init__( + self, coordinator: AdvantageAirCoordinator, light: dict[str, Any] + ) -> None: """Initialize an Advantage Air Light.""" - super().__init__(instance) + super().__init__(coordinator) self._id: str = light["id"] self._attr_unique_id += f"-{self._id}" @@ -59,7 +61,7 @@ def __init__(self, instance: AdvantageAirData, light: dict[str, Any]) -> None: name=light["name"], ) self.async_update_state = self.update_handle_factory( - instance.api.lights.async_update_state, self._id + coordinator.api.lights.async_update_state, self._id ) @property @@ -87,11 +89,13 @@ class AdvantageAirLightDimmable(AdvantageAirLight): _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} - def __init__(self, instance: AdvantageAirData, light: dict[str, Any]) -> None: + def __init__( + self, coordinator: AdvantageAirCoordinator, light: dict[str, Any] + ) -> None: """Initialize an Advantage Air Dimmable Light.""" - super().__init__(instance, light) + super().__init__(coordinator, light) self.async_update_value = self.update_handle_factory( - instance.api.lights.async_update_value, self._id + coordinator.api.lights.async_update_value, self._id ) @property diff --git a/homeassistant/components/advantage_air/models.py b/homeassistant/components/advantage_air/models.py deleted file mode 100644 index 77135644d11597..00000000000000 --- a/homeassistant/components/advantage_air/models.py +++ /dev/null @@ -1,17 +0,0 @@ -"""The Advantage Air integration models.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from advantage_air import advantage_air - -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - - -@dataclass -class AdvantageAirData: - """Data for the Advantage Air integration.""" - - coordinator: DataUpdateCoordinator - api: advantage_air diff --git a/homeassistant/components/advantage_air/quality_scale.yaml b/homeassistant/components/advantage_air/quality_scale.yaml new file mode 100644 index 00000000000000..257d14937f06b0 --- /dev/null +++ b/homeassistant/components/advantage_air/quality_scale.yaml @@ -0,0 +1,99 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: + status: todo + comment: | + Add mock_setup_entry common fixture. + Test unique_id of the entry in happy flow. + Split duplicate entry test from happy flow, use mock_config_entry. + Error flow should end in CREATE_ENTRY to test recovery. + Add data_description for ip_address (and port) to strings.json - tests fail with: + "Translation not found for advantage_air: config.step.user.data_description.ip_address" + config-flow: + status: todo + comment: Data descriptions missing + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: todo + docs-removal-instructions: todo + entity-event-setup: + status: exempt + comment: Entities do not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options to be set. + docs-installation-parameters: done + entity-unavailable: + status: todo + comment: MyZone temp entity should be unavailable when MyZone is disabled rather than returning None. + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: + status: exempt + comment: Integration connects to local device without authentication. + test-coverage: + status: todo + comment: | + Patch the library instead of mocking at integration level. + Split binary sensor tests into multiple tests (enable entities etc). + Split tests into Creation (right entities with right values), Actions (right library calls), and Other behaviors. + + # Gold + devices: + status: todo + comment: Consider making every zone its own device for better naming and room assignment. Breaking change to split cover entities to separate devices. + diagnostics: done + discovery-update-info: + status: exempt + comment: Device is a generic Android device (android-xxxxxxxx) indistinguishable from other Android devices, not discoverable. + discovery: + status: exempt + comment: Check mDNS, DHCP, SSDP confirmed not feasible. Device is a generic Android device (android-xxxxxxxx) indistinguishable from other Android devices. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: AC zones are static per unit and configured on the device itself. + entity-category: done + entity-device-class: + status: todo + comment: Consider using UPDATE device class for app update binary sensor instead of custom. + entity-disabled-by-default: done + entity-translations: todo + exception-translations: + status: todo + comment: HomeAssistantError in entity.py and ServiceValidationError in climate.py + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: Integration does not raise repair issues. + stale-devices: + status: exempt + comment: Zones are part of the AC unit, not separate removable devices. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/advantage_air/select.py b/homeassistant/components/advantage_air/select.py index 320bfd35abaaa6..a8abca25d071de 100644 --- a/homeassistant/components/advantage_air/select.py +++ b/homeassistant/components/advantage_air/select.py @@ -5,8 +5,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AdvantageAirDataConfigEntry +from .coordinator import AdvantageAirCoordinator from .entity import AdvantageAirAcEntity -from .models import AdvantageAirData ADVANTAGE_AIR_INACTIVE = "Inactive" @@ -18,10 +18,12 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir select platform.""" - instance = config_entry.runtime_data + coordinator = config_entry.runtime_data - if aircons := instance.coordinator.data.get("aircons"): - async_add_entities(AdvantageAirMyZone(instance, ac_key) for ac_key in aircons) + if aircons := coordinator.data.get("aircons"): + async_add_entities( + AdvantageAirMyZone(coordinator, ac_key) for ac_key in aircons + ) class AdvantageAirMyZone(AdvantageAirAcEntity, SelectEntity): @@ -30,16 +32,16 @@ class AdvantageAirMyZone(AdvantageAirAcEntity, SelectEntity): _attr_icon = "mdi:home-thermometer" _attr_name = "MyZone" - def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: + def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None: """Initialize an Advantage Air MyZone control.""" - super().__init__(instance, ac_key) + super().__init__(coordinator, ac_key) self._attr_unique_id += "-myzone" self._attr_options = [ADVANTAGE_AIR_INACTIVE] self._number_to_name = {0: ADVANTAGE_AIR_INACTIVE} self._name_to_number = {ADVANTAGE_AIR_INACTIVE: 0} - if "aircons" in instance.coordinator.data: - for zone in instance.coordinator.data["aircons"][ac_key]["zones"].values(): + if "aircons" in coordinator.data: + for zone in coordinator.data["aircons"][ac_key]["zones"].values(): if zone["type"] > 0: self._name_to_number[zone["name"]] = zone["number"] self._number_to_name[zone["number"]] = zone["name"] diff --git a/homeassistant/components/advantage_air/sensor.py b/homeassistant/components/advantage_air/sensor.py index 31be475aeddd4b..59d72a7bacf36b 100644 --- a/homeassistant/components/advantage_air/sensor.py +++ b/homeassistant/components/advantage_air/sensor.py @@ -16,8 +16,8 @@ from . import AdvantageAirDataConfigEntry from .const import ADVANTAGE_AIR_STATE_OPEN +from .coordinator import AdvantageAirCoordinator from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity -from .models import AdvantageAirData ADVANTAGE_AIR_SET_COUNTDOWN_VALUE = "minutes" ADVANTAGE_AIR_SET_COUNTDOWN_UNIT = "min" @@ -32,21 +32,23 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir sensor platform.""" - instance = config_entry.runtime_data + coordinator = config_entry.runtime_data entities: list[SensorEntity] = [] - if aircons := instance.coordinator.data.get("aircons"): + if aircons := coordinator.data.get("aircons"): for ac_key, ac_device in aircons.items(): - entities.append(AdvantageAirTimeTo(instance, ac_key, "On")) - entities.append(AdvantageAirTimeTo(instance, ac_key, "Off")) + entities.append(AdvantageAirTimeTo(coordinator, ac_key, "On")) + entities.append(AdvantageAirTimeTo(coordinator, ac_key, "Off")) for zone_key, zone in ac_device["zones"].items(): # Only show damper and temp sensors when zone is in temperature control if zone["type"] != 0: - entities.append(AdvantageAirZoneVent(instance, ac_key, zone_key)) - entities.append(AdvantageAirZoneTemp(instance, ac_key, zone_key)) + entities.append(AdvantageAirZoneVent(coordinator, ac_key, zone_key)) + entities.append(AdvantageAirZoneTemp(coordinator, ac_key, zone_key)) # Only show wireless signal strength sensors when using wireless sensors if zone["rssi"] > 0: - entities.append(AdvantageAirZoneSignal(instance, ac_key, zone_key)) + entities.append( + AdvantageAirZoneSignal(coordinator, ac_key, zone_key) + ) async_add_entities(entities) @@ -56,9 +58,11 @@ class AdvantageAirTimeTo(AdvantageAirAcEntity, SensorEntity): _attr_native_unit_of_measurement = ADVANTAGE_AIR_SET_COUNTDOWN_UNIT _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, instance: AdvantageAirData, ac_key: str, action: str) -> None: + def __init__( + self, coordinator: AdvantageAirCoordinator, ac_key: str, action: str + ) -> None: """Initialize the Advantage Air timer control.""" - super().__init__(instance, ac_key) + super().__init__(coordinator, ac_key) self.action = action self._time_key = f"countDownTo{action}" self._attr_name = f"Time to {action}" @@ -89,9 +93,11 @@ class AdvantageAirZoneVent(AdvantageAirZoneEntity, SensorEntity): _attr_state_class = SensorStateClass.MEASUREMENT _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: + def __init__( + self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str + ) -> None: """Initialize an Advantage Air Zone Vent Sensor.""" - super().__init__(instance, ac_key, zone_key=zone_key) + super().__init__(coordinator, ac_key, zone_key=zone_key) self._attr_name = f"{self._zone['name']} vent" self._attr_unique_id += "-vent" @@ -117,9 +123,11 @@ class AdvantageAirZoneSignal(AdvantageAirZoneEntity, SensorEntity): _attr_state_class = SensorStateClass.MEASUREMENT _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: + def __init__( + self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str + ) -> None: """Initialize an Advantage Air Zone wireless signal sensor.""" - super().__init__(instance, ac_key, zone_key) + super().__init__(coordinator, ac_key, zone_key) self._attr_name = f"{self._zone['name']} signal" self._attr_unique_id += "-signal" @@ -151,9 +159,11 @@ class AdvantageAirZoneTemp(AdvantageAirZoneEntity, SensorEntity): _attr_entity_registry_enabled_default = False _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: + def __init__( + self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str + ) -> None: """Initialize an Advantage Air Zone Temp Sensor.""" - super().__init__(instance, ac_key, zone_key) + super().__init__(coordinator, ac_key, zone_key) self._attr_name = f"{self._zone['name']} temperature" self._attr_unique_id += "-temp" diff --git a/homeassistant/components/advantage_air/services.py b/homeassistant/components/advantage_air/services.py index a7347234c07eb8..a64d1c9e225e62 100644 --- a/homeassistant/components/advantage_air/services.py +++ b/homeassistant/components/advantage_air/services.py @@ -10,8 +10,6 @@ from .const import DOMAIN -ADVANTAGE_AIR_SERVICE_SET_TIME_TO = "set_time_to" - @callback def async_setup_services(hass: HomeAssistant) -> None: @@ -20,7 +18,7 @@ def async_setup_services(hass: HomeAssistant) -> None: service.async_register_platform_entity_service( hass, DOMAIN, - ADVANTAGE_AIR_SERVICE_SET_TIME_TO, + "set_time_to", entity_domain=SENSOR_DOMAIN, schema={vol.Required("minutes"): cv.positive_int}, func="set_time_to", diff --git a/homeassistant/components/advantage_air/strings.json b/homeassistant/components/advantage_air/strings.json index 719356e0cf2b8e..80e8b15de98c52 100644 --- a/homeassistant/components/advantage_air/strings.json +++ b/homeassistant/components/advantage_air/strings.json @@ -17,6 +17,11 @@ } } }, + "exceptions": { + "update_failed": { + "message": "An error occurred while updating from the Advantage Air API: {error}" + } + }, "services": { "set_time_to": { "description": "Controls timers to turn the system on or off after a set number of minutes.", diff --git a/homeassistant/components/advantage_air/switch.py b/homeassistant/components/advantage_air/switch.py index 8560c9a913887e..d75c14c20e9c01 100644 --- a/homeassistant/components/advantage_air/switch.py +++ b/homeassistant/components/advantage_air/switch.py @@ -13,8 +13,8 @@ ADVANTAGE_AIR_STATE_OFF, ADVANTAGE_AIR_STATE_ON, ) +from .coordinator import AdvantageAirCoordinator from .entity import AdvantageAirAcEntity, AdvantageAirThingEntity -from .models import AdvantageAirData async def async_setup_entry( @@ -24,20 +24,20 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir switch platform.""" - instance = config_entry.runtime_data + coordinator = config_entry.runtime_data entities: list[SwitchEntity] = [] - if aircons := instance.coordinator.data.get("aircons"): + if aircons := coordinator.data.get("aircons"): for ac_key, ac_device in aircons.items(): if ac_device["info"]["freshAirStatus"] != "none": - entities.append(AdvantageAirFreshAir(instance, ac_key)) + entities.append(AdvantageAirFreshAir(coordinator, ac_key)) if ADVANTAGE_AIR_AUTOFAN_ENABLED in ac_device["info"]: - entities.append(AdvantageAirMyFan(instance, ac_key)) + entities.append(AdvantageAirMyFan(coordinator, ac_key)) if ADVANTAGE_AIR_NIGHT_MODE_ENABLED in ac_device["info"]: - entities.append(AdvantageAirNightMode(instance, ac_key)) - if things := instance.coordinator.data.get("myThings"): + entities.append(AdvantageAirNightMode(coordinator, ac_key)) + if things := coordinator.data.get("myThings"): entities.extend( - AdvantageAirRelay(instance, thing) + AdvantageAirRelay(coordinator, thing) for thing in things["things"].values() if thing["channelDipState"] == 8 # 8 = Other relay ) @@ -51,9 +51,9 @@ class AdvantageAirFreshAir(AdvantageAirAcEntity, SwitchEntity): _attr_name = "Fresh air" _attr_device_class = SwitchDeviceClass.SWITCH - def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: + def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None: """Initialize an Advantage Air fresh air control.""" - super().__init__(instance, ac_key) + super().__init__(coordinator, ac_key) self._attr_unique_id += "-freshair" @property @@ -77,9 +77,9 @@ class AdvantageAirMyFan(AdvantageAirAcEntity, SwitchEntity): _attr_name = "MyFan" _attr_device_class = SwitchDeviceClass.SWITCH - def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: + def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None: """Initialize an Advantage Air MyFan control.""" - super().__init__(instance, ac_key) + super().__init__(coordinator, ac_key) self._attr_unique_id += "-myfan" @property @@ -103,9 +103,9 @@ class AdvantageAirNightMode(AdvantageAirAcEntity, SwitchEntity): _attr_name = "MySleep$aver" _attr_device_class = SwitchDeviceClass.SWITCH - def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: + def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None: """Initialize an Advantage Air Night Mode control.""" - super().__init__(instance, ac_key) + super().__init__(coordinator, ac_key) self._attr_unique_id += "-nightmode" @property diff --git a/homeassistant/components/advantage_air/update.py b/homeassistant/components/advantage_air/update.py index 68df31142e30bf..d4903a54839f92 100644 --- a/homeassistant/components/advantage_air/update.py +++ b/homeassistant/components/advantage_air/update.py @@ -7,8 +7,8 @@ from . import AdvantageAirDataConfigEntry from .const import DOMAIN +from .coordinator import AdvantageAirCoordinator from .entity import AdvantageAirEntity -from .models import AdvantageAirData async def async_setup_entry( @@ -18,9 +18,9 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir update platform.""" - instance = config_entry.runtime_data + coordinator = config_entry.runtime_data - async_add_entities([AdvantageAirApp(instance)]) + async_add_entities([AdvantageAirApp(coordinator)]) class AdvantageAirApp(AdvantageAirEntity, UpdateEntity): @@ -28,9 +28,9 @@ class AdvantageAirApp(AdvantageAirEntity, UpdateEntity): _attr_name = "App" - def __init__(self, instance: AdvantageAirData) -> None: + def __init__(self, coordinator: AdvantageAirCoordinator) -> None: """Initialize the Advantage Air App.""" - super().__init__(instance) + super().__init__(coordinator) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.coordinator.data["system"]["rid"])}, manufacturer="Advantage Air", diff --git a/homeassistant/components/aemet/weather.py b/homeassistant/components/aemet/weather.py index 3a17430300d6e0..9b029f9995c921 100644 --- a/homeassistant/components/aemet/weather.py +++ b/homeassistant/components/aemet/weather.py @@ -74,7 +74,7 @@ def __init__( self._attr_unique_id = unique_id @property - def condition(self): + def condition(self) -> str | None: """Return the current condition.""" cond = self.get_aemet_value([AOD_WEATHER, AOD_CONDITION]) return CONDITIONS_MAP.get(cond) @@ -90,31 +90,31 @@ def _async_forecast_hourly(self) -> list[Forecast]: return self.get_aemet_forecast(AOD_FORECAST_HOURLY) @property - def humidity(self): + def humidity(self) -> float | None: """Return the humidity.""" return self.get_aemet_value([AOD_WEATHER, AOD_HUMIDITY]) @property - def native_pressure(self): + def native_pressure(self) -> float | None: """Return the pressure.""" return self.get_aemet_value([AOD_WEATHER, AOD_PRESSURE]) @property - def native_temperature(self): + def native_temperature(self) -> float | None: """Return the temperature.""" return self.get_aemet_value([AOD_WEATHER, AOD_TEMP]) @property - def wind_bearing(self): + def wind_bearing(self) -> float | None: """Return the wind bearing.""" return self.get_aemet_value([AOD_WEATHER, AOD_WIND_DIRECTION]) @property - def native_wind_gust_speed(self): + def native_wind_gust_speed(self) -> float | None: """Return the wind gust speed in native units.""" return self.get_aemet_value([AOD_WEATHER, AOD_WIND_SPEED_MAX]) @property - def native_wind_speed(self): + def native_wind_speed(self) -> float | None: """Return the wind speed.""" return self.get_aemet_value([AOD_WEATHER, AOD_WIND_SPEED]) diff --git a/homeassistant/components/agent_dvr/services.py b/homeassistant/components/agent_dvr/services.py index d80d94427fbd28..b9c5c0f7ec653f 100644 --- a/homeassistant/components/agent_dvr/services.py +++ b/homeassistant/components/agent_dvr/services.py @@ -8,18 +8,12 @@ from .const import DOMAIN -_DEV_EN_ALT = "enable_alerts" -_DEV_DS_ALT = "disable_alerts" -_DEV_EN_REC = "start_recording" -_DEV_DS_REC = "stop_recording" -_DEV_SNAP = "snapshot" - CAMERA_SERVICES = { - _DEV_EN_ALT: "async_enable_alerts", - _DEV_DS_ALT: "async_disable_alerts", - _DEV_EN_REC: "async_start_recording", - _DEV_DS_REC: "async_stop_recording", - _DEV_SNAP: "async_snapshot", + "enable_alerts": "async_enable_alerts", + "disable_alerts": "async_disable_alerts", + "start_recording": "async_start_recording", + "stop_recording": "async_stop_recording", + "snapshot": "async_snapshot", } diff --git a/homeassistant/components/airly/__init__.py b/homeassistant/components/airly/__init__.py index 18ad1c8c402b65..7c26f6062d6266 100644 --- a/homeassistant/components/airly/__init__.py +++ b/homeassistant/components/airly/__init__.py @@ -5,7 +5,7 @@ from datetime import timedelta import logging -from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM +from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_DOMAIN from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -75,9 +75,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirlyConfigEntry) -> boo # Remove air_quality entities from registry if they exist ent_reg = er.async_get(hass) unique_id = f"{coordinator.latitude}-{coordinator.longitude}" - if entity_id := ent_reg.async_get_entity_id( - AIR_QUALITY_PLATFORM, DOMAIN, unique_id - ): + if entity_id := ent_reg.async_get_entity_id(AIR_QUALITY_DOMAIN, DOMAIN, unique_id): _LOGGER.debug("Removing deprecated air_quality entity %s", entity_id) ent_reg.async_remove(entity_id) diff --git a/homeassistant/components/airobot/number.py b/homeassistant/components/airobot/number.py index 8cdd0b56a4c89d..e8d041e9489f96 100644 --- a/homeassistant/components/airobot/number.py +++ b/homeassistant/components/airobot/number.py @@ -93,7 +93,6 @@ async def async_set_native_value(self, value: float) -> None: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="set_value_failed", - translation_placeholders={"error": str(err)}, ) from err else: await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/airobot/strings.json b/homeassistant/components/airobot/strings.json index ecccf553736bd4..e12b5c333bb408 100644 --- a/homeassistant/components/airobot/strings.json +++ b/homeassistant/components/airobot/strings.json @@ -112,7 +112,7 @@ "message": "Failed to set temperature to {temperature}." }, "set_value_failed": { - "message": "Failed to set value: {error}" + "message": "Failed to set value." }, "switch_turn_off_failed": { "message": "Failed to turn off {switch}." diff --git a/homeassistant/components/airos/__init__.py b/homeassistant/components/airos/__init__.py index d449c9a05e8038..a0e573f2f50a22 100644 --- a/homeassistant/components/airos/__init__.py +++ b/homeassistant/components/airos/__init__.py @@ -4,7 +4,16 @@ import logging +from airos.airos6 import AirOS6 from airos.airos8 import AirOS8 +from airos.exceptions import ( + AirOSConnectionAuthenticationError, + AirOSConnectionSetupError, + AirOSDataMissingError, + AirOSDeviceConnectionError, + AirOSKeyDataMissingError, +) +from airos.helpers import DetectDeviceData, async_get_firmware_data from homeassistant.const import ( CONF_HOST, @@ -15,6 +24,11 @@ Platform, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -23,6 +37,7 @@ _PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.SENSOR, ] @@ -38,15 +53,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo hass, verify_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] ) - airos_device = AirOS8( - host=entry.data[CONF_HOST], - username=entry.data[CONF_USERNAME], - password=entry.data[CONF_PASSWORD], - session=session, - use_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL], + conn_data = { + CONF_HOST: entry.data[CONF_HOST], + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + "use_ssl": entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL], + "session": session, + } + + # Determine firmware version before creating the device instance + try: + device_data: DetectDeviceData = await async_get_firmware_data(**conn_data) + except ( + AirOSConnectionSetupError, + AirOSDeviceConnectionError, + TimeoutError, + ) as err: + raise ConfigEntryNotReady from err + except ( + AirOSConnectionAuthenticationError, + AirOSDataMissingError, + ) as err: + raise ConfigEntryAuthFailed from err + except AirOSKeyDataMissingError as err: + raise ConfigEntryError("key_data_missing") from err + except Exception as err: + raise ConfigEntryError("unknown") from err + + airos_class: type[AirOS8 | AirOS6] = ( + AirOS8 if device_data["fw_major"] == 8 else AirOS6 ) - coordinator = AirOSDataUpdateCoordinator(hass, entry, airos_device) + airos_device = airos_class(**conn_data) + + coordinator = AirOSDataUpdateCoordinator(hass, entry, device_data, airos_device) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/airos/binary_sensor.py b/homeassistant/components/airos/binary_sensor.py index 994caeb2071e9f..0154db8dcb511c 100644 --- a/homeassistant/components/airos/binary_sensor.py +++ b/homeassistant/components/airos/binary_sensor.py @@ -4,7 +4,9 @@ from collections.abc import Callable from dataclasses import dataclass -import logging +from typing import Generic, TypeVar + +from airos.data import AirOSDataBaseClass from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -18,25 +20,24 @@ from .coordinator import AirOS8Data, AirOSConfigEntry, AirOSDataUpdateCoordinator from .entity import AirOSEntity -_LOGGER = logging.getLogger(__name__) - PARALLEL_UPDATES = 0 +AirOSDataModel = TypeVar("AirOSDataModel", bound=AirOSDataBaseClass) + @dataclass(frozen=True, kw_only=True) -class AirOSBinarySensorEntityDescription(BinarySensorEntityDescription): +class AirOSBinarySensorEntityDescription( + BinarySensorEntityDescription, + Generic[AirOSDataModel], +): """Describe an AirOS binary sensor.""" - value_fn: Callable[[AirOS8Data], bool] + value_fn: Callable[[AirOSDataModel], bool] -BINARY_SENSORS: tuple[AirOSBinarySensorEntityDescription, ...] = ( - AirOSBinarySensorEntityDescription( - key="portfw", - translation_key="port_forwarding", - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data.portfw, - ), +AirOS8BinarySensorEntityDescription = AirOSBinarySensorEntityDescription[AirOS8Data] + +COMMON_BINARY_SENSORS: tuple[AirOSBinarySensorEntityDescription, ...] = ( AirOSBinarySensorEntityDescription( key="dhcp_client", translation_key="dhcp_client", @@ -52,14 +53,6 @@ class AirOSBinarySensorEntityDescription(BinarySensorEntityDescription): value_fn=lambda data: data.services.dhcpd, entity_registry_enabled_default=False, ), - AirOSBinarySensorEntityDescription( - key="dhcp6_server", - translation_key="dhcp6_server", - device_class=BinarySensorDeviceClass.RUNNING, - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data.services.dhcp6d_stateful, - entity_registry_enabled_default=False, - ), AirOSBinarySensorEntityDescription( key="pppoe", translation_key="pppoe", @@ -70,6 +63,23 @@ class AirOSBinarySensorEntityDescription(BinarySensorEntityDescription): ), ) +AIROS8_BINARY_SENSORS: tuple[AirOS8BinarySensorEntityDescription, ...] = ( + AirOS8BinarySensorEntityDescription( + key="portfw", + translation_key="port_forwarding", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.portfw, + ), + AirOS8BinarySensorEntityDescription( + key="dhcp6_server", + translation_key="dhcp6_server", + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.services.dhcp6d_stateful, + entity_registry_enabled_default=False, + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -79,9 +89,18 @@ async def async_setup_entry( """Set up the AirOS binary sensors from a config entry.""" coordinator = config_entry.runtime_data - async_add_entities( - AirOSBinarySensor(coordinator, description) for description in BINARY_SENSORS - ) + entities = [ + AirOSBinarySensor(coordinator, description) + for description in COMMON_BINARY_SENSORS + ] + + if coordinator.device_data["fw_major"] == 8: + entities.extend( + AirOSBinarySensor(coordinator, description) + for description in AIROS8_BINARY_SENSORS + ) + + async_add_entities(entities) class AirOSBinarySensor(AirOSEntity, BinarySensorEntity): diff --git a/homeassistant/components/airos/button.py b/homeassistant/components/airos/button.py new file mode 100644 index 00000000000000..44eca04b9b6473 --- /dev/null +++ b/homeassistant/components/airos/button.py @@ -0,0 +1,69 @@ +"""AirOS button component for Home Assistant.""" + +from __future__ import annotations + +from airos.exceptions import AirOSException + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import DOMAIN, AirOSConfigEntry, AirOSDataUpdateCoordinator +from .entity import AirOSEntity + +PARALLEL_UPDATES = 0 + +REBOOT_BUTTON = ButtonEntityDescription( + key="reboot", + device_class=ButtonDeviceClass.RESTART, + entity_registry_enabled_default=False, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: AirOSConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the AirOS button from a config entry.""" + async_add_entities([AirOSRebootButton(config_entry.runtime_data, REBOOT_BUTTON)]) + + +class AirOSRebootButton(AirOSEntity, ButtonEntity): + """Button to reboot device.""" + + entity_description: ButtonEntityDescription + + def __init__( + self, + coordinator: AirOSDataUpdateCoordinator, + description: ButtonEntityDescription, + ) -> None: + """Initialize the AirOS client button.""" + super().__init__(coordinator) + + self.entity_description = description + self._attr_unique_id = f"{coordinator.data.derived.mac}_{description.key}" + + async def async_press(self) -> None: + """Handle the button press to reboot the device.""" + try: + await self.coordinator.airos_device.login() + result = await self.coordinator.airos_device.reboot() + + except AirOSException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + + if not result: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="reboot_failed", + ) from None diff --git a/homeassistant/components/airos/config_flow.py b/homeassistant/components/airos/config_flow.py index 14a5347eb35d61..4e79ba932d5d1e 100644 --- a/homeassistant/components/airos/config_flow.py +++ b/homeassistant/components/airos/config_flow.py @@ -2,17 +2,24 @@ from __future__ import annotations +import asyncio from collections.abc import Mapping import logging from typing import Any +from airos.airos6 import AirOS6 +from airos.airos8 import AirOS8 +from airos.discovery import airos_discover_devices from airos.exceptions import ( AirOSConnectionAuthenticationError, AirOSConnectionSetupError, AirOSDataMissingError, AirOSDeviceConnectionError, + AirOSEndpointError, AirOSKeyDataMissingError, + AirOSListenerError, ) +from airos.helpers import DetectDeviceData, async_get_firmware_data import voluptuous as vol from homeassistant.config_entries import ( @@ -30,21 +37,36 @@ ) from homeassistant.data_entry_flow import section 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 DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS -from .coordinator import AirOS8 +from .const import ( + DEFAULT_SSL, + DEFAULT_USERNAME, + DEFAULT_VERIFY_SSL, + DEVICE_NAME, + DOMAIN, + HOSTNAME, + IP_ADDRESS, + MAC_ADDRESS, + SECTION_ADVANCED_SETTINGS, +) _LOGGER = logging.getLogger(__name__) -STEP_USER_DATA_SCHEMA = vol.Schema( +AirOSDeviceDetect = AirOS8 | AirOS6 + +# Discovery duration in seconds, airOS announces every 20 seconds +DISCOVER_INTERVAL: int = 30 + +STEP_DISCOVERY_DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_HOST): str, - vol.Required(CONF_USERNAME, default="ubnt"): str, + vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): str, vol.Required(CONF_PASSWORD): str, vol.Required(SECTION_ADVANCED_SETTINGS): section( vol.Schema( @@ -58,6 +80,10 @@ } ) +STEP_MANUAL_DATA_SCHEMA = STEP_DISCOVERY_DATA_SCHEMA.extend( + {vol.Required(CONF_HOST): str} +) + class AirOSConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Ubiquiti airOS.""" @@ -65,14 +91,29 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 2 MINOR_VERSION = 1 + _discovery_task: asyncio.Task | None = None + def __init__(self) -> None: """Initialize the config flow.""" super().__init__() - self.airos_device: AirOS8 + self.airos_device: AirOSDeviceDetect self.errors: dict[str, str] = {} + self.discovered_devices: dict[str, dict[str, Any]] = {} + self.discovery_abort_reason: str | None = None + self.selected_device_info: dict[str, Any] = {} async def async_step_user( self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + self.errors = {} + + return self.async_show_menu( + step_id="user", menu_options=["discovery", "manual"] + ) + + async def async_step_manual( + self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the manual input of host and credentials.""" self.errors = {} @@ -84,7 +125,7 @@ async def async_step_user( data=validated_info["data"], ) return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=self.errors + step_id="manual", data_schema=STEP_MANUAL_DATA_SCHEMA, errors=self.errors ) async def _validate_and_get_device_info( @@ -98,16 +139,14 @@ async def _validate_and_get_device_info( verify_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL], ) - airos_device = AirOS8( - host=config_data[CONF_HOST], - username=config_data[CONF_USERNAME], - password=config_data[CONF_PASSWORD], - session=session, - use_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_SSL], - ) try: - await airos_device.login() - airos_data = await airos_device.status() + device_data: DetectDeviceData = await async_get_firmware_data( + host=config_data[CONF_HOST], + username=config_data[CONF_USERNAME], + password=config_data[CONF_PASSWORD], + session=session, + use_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_SSL], + ) except ( AirOSConnectionSetupError, @@ -122,14 +161,14 @@ async def _validate_and_get_device_info( _LOGGER.exception("Unexpected exception during credential validation") self.errors["base"] = "unknown" else: - await self.async_set_unique_id(airos_data.derived.mac) + await self.async_set_unique_id(device_data["mac"]) if self.source in [SOURCE_REAUTH, SOURCE_RECONFIGURE]: self._abort_if_unique_id_mismatch() else: self._abort_if_unique_id_configured() - return {"title": airos_data.host.hostname, "data": config_data} + return {"title": device_data["hostname"], "data": config_data} return None @@ -220,3 +259,175 @@ async def async_step_reconfigure( ), errors=self.errors, ) + + async def async_step_discovery( + self, + discovery_info: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Start the discovery process.""" + if self._discovery_task and self._discovery_task.done(): + self._discovery_task = None + + # Handle appropriate 'errors' as abort through progress_done + if self.discovery_abort_reason: + return self.async_show_progress_done( + next_step_id=self.discovery_abort_reason + ) + + # Abort through progress_done if no devices were found + if not self.discovered_devices: + _LOGGER.debug( + "No (new or unconfigured) airOS devices found during discovery" + ) + return self.async_show_progress_done( + next_step_id="discovery_no_devices" + ) + + # Skip selecting a device if only one new/unconfigured device was found + if len(self.discovered_devices) == 1: + self.selected_device_info = list(self.discovered_devices.values())[0] + return self.async_show_progress_done(next_step_id="configure_device") + + return self.async_show_progress_done(next_step_id="select_device") + + if not self._discovery_task: + self.discovered_devices = {} + self._discovery_task = self.hass.async_create_task( + self._async_run_discovery_with_progress() + ) + + # Show the progress bar and wait for discovery to complete + return self.async_show_progress( + step_id="discovery", + progress_action="discovering", + progress_task=self._discovery_task, + description_placeholders={"seconds": str(DISCOVER_INTERVAL)}, + ) + + async def async_step_select_device( + self, + discovery_info: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Select a discovered device.""" + if discovery_info is not None: + selected_mac = discovery_info[MAC_ADDRESS] + self.selected_device_info = self.discovered_devices[selected_mac] + return await self.async_step_configure_device() + + list_options = { + mac: f"{device.get(HOSTNAME, mac)} ({device.get(IP_ADDRESS, DEVICE_NAME)})" + for mac, device in self.discovered_devices.items() + } + + return self.async_show_form( + step_id="select_device", + data_schema=vol.Schema({vol.Required(MAC_ADDRESS): vol.In(list_options)}), + ) + + async def async_step_configure_device( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Configure the selected device.""" + self.errors = {} + + if user_input is not None: + config_data = { + **user_input, + CONF_HOST: self.selected_device_info[IP_ADDRESS], + } + validated_info = await self._validate_and_get_device_info(config_data) + + if validated_info: + return self.async_create_entry( + title=validated_info["title"], + data=validated_info["data"], + ) + + device_name = self.selected_device_info.get( + HOSTNAME, self.selected_device_info.get(IP_ADDRESS, DEVICE_NAME) + ) + return self.async_show_form( + step_id="configure_device", + data_schema=STEP_DISCOVERY_DATA_SCHEMA, + errors=self.errors, + description_placeholders={"device_name": device_name}, + ) + + async def _async_run_discovery_with_progress(self) -> None: + """Run discovery with an embedded progress update loop.""" + progress_bar = self.hass.async_create_task(self._async_update_progress_bar()) + + known_mac_addresses = { + entry.unique_id.lower() + for entry in self.hass.config_entries.async_entries(DOMAIN) + if entry.unique_id + } + + try: + devices = await airos_discover_devices(DISCOVER_INTERVAL) + except AirOSEndpointError: + self.discovery_abort_reason = "discovery_detect_error" + except AirOSListenerError: + self.discovery_abort_reason = "discovery_listen_error" + except Exception: + self.discovery_abort_reason = "discovery_failed" + _LOGGER.exception("An error occurred during discovery") + else: + self.discovered_devices = { + mac_addr: info + for mac_addr, info in devices.items() + if mac_addr.lower() not in known_mac_addresses + } + _LOGGER.debug( + "Discovery task finished. Found %s new devices", + len(self.discovered_devices), + ) + finally: + progress_bar.cancel() + + async def _async_update_progress_bar(self) -> None: + """Update progress bar every second.""" + try: + for i in range(DISCOVER_INTERVAL): + progress = (i + 1) / DISCOVER_INTERVAL + self.async_update_progress(progress) + await asyncio.sleep(1) + except asyncio.CancelledError: + pass + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Automatically handle a DHCP discovered IP change.""" + ip_address = discovery_info.ip + # python-airos defaults to upper for derived mac_address + normalized_mac = format_mac(discovery_info.macaddress).upper() + await self.async_set_unique_id(normalized_mac) + + self._abort_if_unique_id_configured(updates={CONF_HOST: ip_address}) + return self.async_abort(reason="unreachable") + + async def async_step_discovery_no_devices( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Abort if discovery finds no (unconfigured) devices.""" + return self.async_abort(reason="no_devices_found") + + async def async_step_discovery_listen_error( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Abort if discovery is unable to listen on the port.""" + return self.async_abort(reason="listen_error") + + async def async_step_discovery_detect_error( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Abort if discovery receives incorrect broadcasts.""" + return self.async_abort(reason="detect_error") + + async def async_step_discovery_failed( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Abort if discovery fails for other reasons.""" + return self.async_abort(reason="discovery_failed") diff --git a/homeassistant/components/airos/const.py b/homeassistant/components/airos/const.py index 29a5f6a9e55b2a..548c4eff805de3 100644 --- a/homeassistant/components/airos/const.py +++ b/homeassistant/components/airos/const.py @@ -12,3 +12,10 @@ DEFAULT_SSL = True SECTION_ADVANCED_SETTINGS = "advanced_settings" + +# Discovery related +DEFAULT_USERNAME = "ubnt" +HOSTNAME = "hostname" +IP_ADDRESS = "ip_address" +MAC_ADDRESS = "mac_address" +DEVICE_NAME = "airOS device" diff --git a/homeassistant/components/airos/coordinator.py b/homeassistant/components/airos/coordinator.py index b1f9a770c0aece..52ca88faebeb5d 100644 --- a/homeassistant/components/airos/coordinator.py +++ b/homeassistant/components/airos/coordinator.py @@ -4,6 +4,7 @@ import logging +from airos.airos6 import AirOS6, AirOS6Data from airos.airos8 import AirOS8, AirOS8Data from airos.exceptions import ( AirOSConnectionAuthenticationError, @@ -11,6 +12,7 @@ AirOSDataMissingError, AirOSDeviceConnectionError, ) +from airos.helpers import DetectDeviceData from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -21,19 +23,28 @@ _LOGGER = logging.getLogger(__name__) +AirOSDeviceDetect = AirOS8 | AirOS6 +AirOSDataDetect = AirOS8Data | AirOS6Data + type AirOSConfigEntry = ConfigEntry[AirOSDataUpdateCoordinator] -class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOS8Data]): +class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSDataDetect]): """Class to manage fetching AirOS data from single endpoint.""" + airos_device: AirOSDeviceDetect config_entry: AirOSConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: AirOSConfigEntry, airos_device: AirOS8 + self, + hass: HomeAssistant, + config_entry: AirOSConfigEntry, + device_data: DetectDeviceData, + airos_device: AirOSDeviceDetect, ) -> None: """Initialize the coordinator.""" self.airos_device = airos_device + self.device_data = device_data super().__init__( hass, _LOGGER, @@ -42,7 +53,7 @@ def __init__( update_interval=SCAN_INTERVAL, ) - async def _async_update_data(self) -> AirOS8Data: + async def _async_update_data(self) -> AirOSDataDetect: """Fetch data from AirOS.""" try: await self.airos_device.login() @@ -62,7 +73,7 @@ async def _async_update_data(self) -> AirOS8Data: translation_domain=DOMAIN, translation_key="cannot_connect", ) from err - except (AirOSDataMissingError,) as err: + except AirOSDataMissingError as err: _LOGGER.error("Expected data not returned by airOS device: %s", err) raise UpdateFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/airos/manifest.json b/homeassistant/components/airos/manifest.json index a4b09458859fa8..75d4a7d0a4a1db 100644 --- a/homeassistant/components/airos/manifest.json +++ b/homeassistant/components/airos/manifest.json @@ -3,9 +3,10 @@ "name": "Ubiquiti airOS", "codeowners": ["@CoMPaTech"], "config_flow": true, + "dhcp": [{ "registered_devices": true }], "documentation": "https://www.home-assistant.io/integrations/airos", "integration_type": "device", "iot_class": "local_polling", - "quality_scale": "silver", - "requirements": ["airos==0.6.3"] + "quality_scale": "platinum", + "requirements": ["airos==0.6.4"] } diff --git a/homeassistant/components/airos/quality_scale.yaml b/homeassistant/components/airos/quality_scale.yaml index b234afdc485a19..419ffe903a586f 100644 --- a/homeassistant/components/airos/quality_scale.yaml +++ b/homeassistant/components/airos/quality_scale.yaml @@ -42,16 +42,20 @@ rules: # Gold devices: done diagnostics: done - discovery-update-info: todo - discovery: todo + discovery-update-info: done + discovery: + status: exempt + comment: No way to detect device on the network docs-data-update: done - docs-examples: todo + docs-examples: done docs-known-limitations: done docs-supported-devices: done docs-supported-functions: done docs-troubleshooting: done docs-use-cases: done - dynamic-devices: todo + dynamic-devices: + status: exempt + comment: single airOS device per config entry; peer/remote endpoints are not modeled as child devices/entities at this time entity-category: done entity-device-class: done entity-disabled-by-default: done @@ -61,8 +65,10 @@ rules: status: exempt comment: no (custom) icons used or envisioned reconfiguration-flow: done - repair-issues: todo - stale-devices: todo + repair-issues: done + stale-devices: + status: exempt + comment: single airOS device per config entry; peer/remote endpoints are not modeled as child devices/entities at this time # Platinum async-dependency: done diff --git a/homeassistant/components/airos/sensor.py b/homeassistant/components/airos/sensor.py index 63c7f8d1e2efe4..8b0673e241c74a 100644 --- a/homeassistant/components/airos/sensor.py +++ b/homeassistant/components/airos/sensor.py @@ -5,8 +5,14 @@ from collections.abc import Callable from dataclasses import dataclass import logging +from typing import Generic, TypeVar -from airos.data import DerivedWirelessMode, DerivedWirelessRole, NetRole +from airos.data import ( + AirOSDataBaseClass, + DerivedWirelessMode, + DerivedWirelessRole, + NetRole, +) from homeassistant.components.sensor import ( SensorDeviceClass, @@ -37,15 +43,19 @@ PARALLEL_UPDATES = 0 +AirOSDataModel = TypeVar("AirOSDataModel", bound=AirOSDataBaseClass) + @dataclass(frozen=True, kw_only=True) -class AirOSSensorEntityDescription(SensorEntityDescription): +class AirOSSensorEntityDescription(SensorEntityDescription, Generic[AirOSDataModel]): """Describe an AirOS sensor.""" - value_fn: Callable[[AirOS8Data], StateType] + value_fn: Callable[[AirOSDataModel], StateType] -SENSORS: tuple[AirOSSensorEntityDescription, ...] = ( +AirOS8SensorEntityDescription = AirOSSensorEntityDescription[AirOS8Data] + +COMMON_SENSORS: tuple[AirOSSensorEntityDescription, ...] = ( AirOSSensorEntityDescription( key="host_cpuload", translation_key="host_cpuload", @@ -75,54 +85,6 @@ class AirOSSensorEntityDescription(SensorEntityDescription): translation_key="wireless_essid", value_fn=lambda data: data.wireless.essid, ), - AirOSSensorEntityDescription( - key="wireless_antenna_gain", - translation_key="wireless_antenna_gain", - native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, - device_class=SensorDeviceClass.SIGNAL_STRENGTH, - state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: data.wireless.antenna_gain, - ), - AirOSSensorEntityDescription( - key="wireless_throughput_tx", - translation_key="wireless_throughput_tx", - native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, - device_class=SensorDeviceClass.DATA_RATE, - state_class=SensorStateClass.MEASUREMENT, - suggested_display_precision=0, - suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, - value_fn=lambda data: data.wireless.throughput.tx, - ), - AirOSSensorEntityDescription( - key="wireless_throughput_rx", - translation_key="wireless_throughput_rx", - native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, - device_class=SensorDeviceClass.DATA_RATE, - state_class=SensorStateClass.MEASUREMENT, - suggested_display_precision=0, - suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, - value_fn=lambda data: data.wireless.throughput.rx, - ), - AirOSSensorEntityDescription( - key="wireless_polling_dl_capacity", - translation_key="wireless_polling_dl_capacity", - native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, - device_class=SensorDeviceClass.DATA_RATE, - state_class=SensorStateClass.MEASUREMENT, - suggested_display_precision=0, - suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, - value_fn=lambda data: data.wireless.polling.dl_capacity, - ), - AirOSSensorEntityDescription( - key="wireless_polling_ul_capacity", - translation_key="wireless_polling_ul_capacity", - native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, - device_class=SensorDeviceClass.DATA_RATE, - state_class=SensorStateClass.MEASUREMENT, - suggested_display_precision=0, - suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, - value_fn=lambda data: data.wireless.polling.ul_capacity, - ), AirOSSensorEntityDescription( key="host_uptime", translation_key="host_uptime", @@ -158,6 +120,57 @@ class AirOSSensorEntityDescription(SensorEntityDescription): options=WIRELESS_ROLE_OPTIONS, entity_registry_enabled_default=False, ), + AirOSSensorEntityDescription( + key="wireless_antenna_gain", + translation_key="wireless_antenna_gain", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.wireless.antenna_gain, + ), + AirOSSensorEntityDescription( + key="wireless_polling_dl_capacity", + translation_key="wireless_polling_dl_capacity", + native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, + device_class=SensorDeviceClass.DATA_RATE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, + value_fn=lambda data: data.wireless.polling.dl_capacity, + ), + AirOSSensorEntityDescription( + key="wireless_polling_ul_capacity", + translation_key="wireless_polling_ul_capacity", + native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, + device_class=SensorDeviceClass.DATA_RATE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, + value_fn=lambda data: data.wireless.polling.ul_capacity, + ), +) + +AIROS8_SENSORS: tuple[AirOS8SensorEntityDescription, ...] = ( + AirOS8SensorEntityDescription( + key="wireless_throughput_tx", + translation_key="wireless_throughput_tx", + native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, + device_class=SensorDeviceClass.DATA_RATE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, + value_fn=lambda data: data.wireless.throughput.tx, + ), + AirOS8SensorEntityDescription( + key="wireless_throughput_rx", + translation_key="wireless_throughput_rx", + native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, + device_class=SensorDeviceClass.DATA_RATE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, + value_fn=lambda data: data.wireless.throughput.rx, + ), ) @@ -169,7 +182,14 @@ async def async_setup_entry( """Set up the AirOS sensors from a config entry.""" coordinator = config_entry.runtime_data - async_add_entities(AirOSSensor(coordinator, description) for description in SENSORS) + entities = [AirOSSensor(coordinator, description) for description in COMMON_SENSORS] + + if coordinator.device_data["fw_major"] == 8: + entities.extend( + AirOSSensor(coordinator, description) for description in AIROS8_SENSORS + ) + + async_add_entities(entities) class AirOSSensor(AirOSEntity, SensorEntity): diff --git a/homeassistant/components/airos/strings.json b/homeassistant/components/airos/strings.json index a8f052a29ab23d..56026eac5529aa 100644 --- a/homeassistant/components/airos/strings.json +++ b/homeassistant/components/airos/strings.json @@ -2,6 +2,10 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "detect_error": "Unable to process discovered devices data, check the documentation for supported devices", + "discovery_failed": "Unable to start discovery, check logs for details", + "listen_error": "Unable to start listening for devices", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "unique_id_mismatch": "Re-authentication should be used for the same device not a new one" @@ -13,37 +17,36 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "flow_title": "Ubiquiti airOS device", + "progress": { + "connecting": "Connecting to the airOS device", + "discovering": "Listening for any airOS devices for {seconds} seconds" + }, "step": { - "reauth_confirm": { - "data": { - "password": "[%key:common::config_flow::data::password%]" - }, - "data_description": { - "password": "[%key:component::airos::config::step::user::data_description::password%]" - } - }, - "reconfigure": { + "configure_device": { "data": { - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" }, "data_description": { - "password": "[%key:component::airos::config::step::user::data_description::password%]" + "password": "[%key:component::airos::config::step::manual::data_description::password%]", + "username": "[%key:component::airos::config::step::manual::data_description::username%]" }, + "description": "Enter the username and password for {device_name}", "sections": { "advanced_settings": { "data": { - "ssl": "[%key:component::airos::config::step::user::sections::advanced_settings::data::ssl%]", + "ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "ssl": "[%key:component::airos::config::step::user::sections::advanced_settings::data_description::ssl%]", - "verify_ssl": "[%key:component::airos::config::step::user::sections::advanced_settings::data_description::verify_ssl%]" + "ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::ssl%]", + "verify_ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::verify_ssl%]" }, - "name": "[%key:component::airos::config::step::user::sections::advanced_settings::name%]" + "name": "[%key:component::airos::config::step::manual::sections::advanced_settings::name%]" } } }, - "user": { + "manual": { "data": { "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]", @@ -67,6 +70,49 @@ "name": "Advanced settings" } } + }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::airos::config::step::manual::data_description::password%]" + } + }, + "reconfigure": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::airos::config::step::manual::data_description::password%]" + }, + "sections": { + "advanced_settings": { + "data": { + "ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data::ssl%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::ssl%]", + "verify_ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::verify_ssl%]" + }, + "name": "[%key:component::airos::config::step::manual::sections::advanced_settings::name%]" + } + } + }, + "select_device": { + "data": { + "mac_address": "Select the device to configure" + }, + "data_description": { + "mac_address": "Select the device MAC address" + } + }, + "user": { + "menu_options": { + "discovery": "Listen for airOS devices on the network", + "manual": "Manually configure airOS device" + } } } }, @@ -157,6 +203,9 @@ }, "key_data_missing": { "message": "Key data not returned from device" + }, + "reboot_failed": { + "message": "The device did not accept the reboot request. Try again, or check your device web interface for errors." } } } diff --git a/homeassistant/components/airq/config_flow.py b/homeassistant/components/airq/config_flow.py index f87b73b5283eea..391d9632e6d995 100644 --- a/homeassistant/components/airq/config_flow.py +++ b/homeassistant/components/airq/config_flow.py @@ -18,6 +18,10 @@ SchemaOptionsFlowHandler, ) from homeassistant.helpers.selector import BooleanSelector +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from .const import CONF_CLIP_NEGATIVE, CONF_RETURN_AVERAGE, DOMAIN @@ -46,6 +50,9 @@ class AirQConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + _discovered_host: str + _discovered_name: str + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -90,6 +97,58 @@ async def async_step_user( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery of an air-Q device.""" + self._discovered_host = discovery_info.host + self._discovered_name = discovery_info.properties.get("devicename", "air-Q") + device_id = discovery_info.properties.get(ATTR_PROPERTIES_ID) + + if not device_id: + return self.async_abort(reason="incomplete_discovery") + + await self.async_set_unique_id(device_id) + self._abort_if_unique_id_configured( + updates={CONF_IP_ADDRESS: self._discovered_host}, + reload_on_update=True, + ) + + self.context["title_placeholders"] = {"name": self._discovered_name} + + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle user confirmation of a discovered air-Q device.""" + errors: dict[str, str] = {} + + if user_input is not None: + session = async_get_clientsession(self.hass) + airq = AirQ(self._discovered_host, user_input[CONF_PASSWORD], session) + try: + await airq.validate() + except ClientConnectionError: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + else: + return self.async_create_entry( + title=self._discovered_name, + data={ + CONF_IP_ADDRESS: self._discovered_host, + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + + return self.async_show_form( + step_id="discovery_confirm", + data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}), + description_placeholders={"name": self._discovered_name}, + errors=errors, + ) + @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/airq/diagnostics.py b/homeassistant/components/airq/diagnostics.py new file mode 100644 index 00000000000000..17299991355e65 --- /dev/null +++ b/homeassistant/components/airq/diagnostics.py @@ -0,0 +1,36 @@ +"""Diagnostics support for air-Q.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_UNIQUE_ID +from homeassistant.core import HomeAssistant + +from . import AirQConfigEntry + +REDACT_CONFIG = {CONF_PASSWORD, CONF_UNIQUE_ID, CONF_IP_ADDRESS, "title"} +REDACT_DEVICE_INFO = {"identifiers", "name"} +REDACT_COORDINATOR_DATA = {"DeviceID"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: AirQConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + + return { + "config_entry": async_redact_data(entry.as_dict(), REDACT_CONFIG), + "device_info": async_redact_data( + dict(coordinator.device_info), REDACT_DEVICE_INFO + ), + "coordinator_data": async_redact_data( + coordinator.data, REDACT_COORDINATOR_DATA + ), + "options": { + "clip_negative": coordinator.clip_negative, + "return_average": coordinator.return_average, + }, + } diff --git a/homeassistant/components/airq/manifest.json b/homeassistant/components/airq/manifest.json index 3610688a113594..5c5a17e9b85ed1 100644 --- a/homeassistant/components/airq/manifest.json +++ b/homeassistant/components/airq/manifest.json @@ -7,5 +7,13 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioairq"], - "requirements": ["aioairq==0.4.7"] + "requirements": ["aioairq==0.4.7"], + "zeroconf": [ + { + "properties": { + "device": "air-q" + }, + "type": "_http._tcp.local." + } + ] } diff --git a/homeassistant/components/airq/strings.json b/homeassistant/components/airq/strings.json index 239fee4e29703d..98926534a190fa 100644 --- a/homeassistant/components/airq/strings.json +++ b/homeassistant/components/airq/strings.json @@ -1,14 +1,23 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "incomplete_discovery": "The discovered air-Q device did not provide a device ID. Ensure the firmware is up to date." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_input": "[%key:common::config_flow::error::invalid_host%]" }, + "flow_title": "{name}", "step": { + "discovery_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "description": "Do you want to set up **{name}**?", + "title": "Set up air-Q" + }, "user": { "data": { "ip_address": "[%key:common::config_flow::data::ip%]", diff --git a/homeassistant/components/airtouch4/climate.py b/homeassistant/components/airtouch4/climate.py index 3cb6a78128bd95..72b66db778f3b5 100644 --- a/homeassistant/components/airtouch4/climate.py +++ b/homeassistant/components/airtouch4/climate.py @@ -117,23 +117,23 @@ def _handle_coordinator_update(self): return super()._handle_coordinator_update() @property - def current_temperature(self): + def current_temperature(self) -> int: """Return the current temperature.""" return self._unit.Temperature @property - def fan_mode(self): + def fan_mode(self) -> str: """Return fan mode of the AC this group belongs to.""" return AT_TO_HA_FAN_SPEED[self._airtouch.acs[self._ac_number].AcFanSpeed] @property - def fan_modes(self): + def fan_modes(self) -> list[str]: """Return the list of available fan modes.""" airtouch_fan_speeds = self._airtouch.GetSupportedFanSpeedsForAc(self._ac_number) return [AT_TO_HA_FAN_SPEED[speed] for speed in airtouch_fan_speeds] @property - def hvac_mode(self): + def hvac_mode(self) -> HVACMode: """Return hvac target hvac state.""" is_off = self._unit.PowerState == "Off" if is_off: @@ -236,17 +236,17 @@ def max_temp(self) -> float: return self._airtouch.acs[self._unit.BelongsToAc].MaxSetpoint @property - def current_temperature(self): + def current_temperature(self) -> int: """Return the current temperature.""" return self._unit.Temperature @property - def target_temperature(self): + def target_temperature(self) -> int: """Return the temperature we are trying to reach.""" return self._unit.TargetSetpoint @property - def hvac_mode(self): + def hvac_mode(self) -> HVACMode: """Return hvac target hvac state.""" # there are other power states that aren't 'on' but still count as on (eg. 'Turbo') is_off = self._unit.PowerState == "Off" @@ -272,12 +272,12 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: self.async_write_ha_state() @property - def fan_mode(self): + def fan_mode(self) -> str: """Return fan mode of the AC this group belongs to.""" return AT_TO_HA_FAN_SPEED[self._airtouch.acs[self._unit.BelongsToAc].AcFanSpeed] @property - def fan_modes(self): + def fan_modes(self) -> list[str]: """Return the list of available fan modes.""" airtouch_fan_speeds = self._airtouch.GetSupportedFanSpeedsByGroup( self._group_number diff --git a/homeassistant/components/airtouch5/manifest.json b/homeassistant/components/airtouch5/manifest.json index 7c7a1c4dd94dc0..15cdc3cc9b7d41 100644 --- a/homeassistant/components/airtouch5/manifest.json +++ b/homeassistant/components/airtouch5/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["airtouch5py"], - "requirements": ["airtouch5py==0.3.0"] + "requirements": ["airtouch5py==0.4.0"] } diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index d2e5e7169b92a9..9d4756cdd3999e 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -7,13 +7,7 @@ from math import ceil from typing import Any -from pyairvisual.cloud_api import ( - CloudAPI, - InvalidKeyError, - KeyExpiredError, - UnauthorizedError, -) -from pyairvisual.errors import AirVisualError +from pyairvisual.cloud_api import CloudAPI from homeassistant.components import automation from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry @@ -28,14 +22,12 @@ Platform, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import ( aiohttp_client, device_registry as dr, entity_registry as er, ) from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( CONF_CITY, @@ -47,8 +39,7 @@ INTEGRATION_TYPE_NODE_PRO, LOGGER, ) - -type AirVisualConfigEntry = ConfigEntry[DataUpdateCoordinator] +from .coordinator import AirVisualConfigEntry, AirVisualDataUpdateCoordinator # We use a raw string for the airvisual_pro domain (instead of importing the actual # constant) so that we can avoid listing it as a dependency: @@ -85,8 +76,8 @@ def async_get_cloud_api_update_interval( @callback def async_get_cloud_coordinators_by_api_key( hass: HomeAssistant, api_key: str -) -> list[DataUpdateCoordinator]: - """Get all DataUpdateCoordinator objects related to a particular API key.""" +) -> list[AirVisualDataUpdateCoordinator]: + """Get all AirVisualDataUpdateCoordinator objects related to a particular API key.""" return [ entry.runtime_data for entry in hass.config_entries.async_entries(DOMAIN) @@ -180,38 +171,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirVisualConfigEntry) -> websession = aiohttp_client.async_get_clientsession(hass) cloud_api = CloudAPI(entry.data[CONF_API_KEY], session=websession) - async def async_update_data() -> dict[str, Any]: - """Get new data from the API.""" - if CONF_CITY in entry.data: - api_coro = cloud_api.air_quality.city( - entry.data[CONF_CITY], - entry.data[CONF_STATE], - entry.data[CONF_COUNTRY], - ) - else: - api_coro = cloud_api.air_quality.nearest_city( - entry.data[CONF_LATITUDE], - entry.data[CONF_LONGITUDE], - ) - - try: - return await api_coro - except (InvalidKeyError, KeyExpiredError, UnauthorizedError) as ex: - raise ConfigEntryAuthFailed from ex - except AirVisualError as err: - raise UpdateFailed(f"Error while retrieving data: {err}") from err - - coordinator = DataUpdateCoordinator( + coordinator = AirVisualDataUpdateCoordinator( hass, - LOGGER, - config_entry=entry, + entry, + cloud_api, name=async_get_geography_id(entry.data), - # We give a placeholder update interval in order to create the coordinator; - # then, below, we use the coordinator's presence (along with any other - # coordinators using the same API key) to calculate an actual, leveled - # update interval: - update_interval=timedelta(minutes=5), - update_method=async_update_data, ) entry.async_on_unload(entry.add_update_listener(async_reload_entry)) diff --git a/homeassistant/components/airvisual/coordinator.py b/homeassistant/components/airvisual/coordinator.py new file mode 100644 index 00000000000000..42c753014ce833 --- /dev/null +++ b/homeassistant/components/airvisual/coordinator.py @@ -0,0 +1,72 @@ +"""Define an AirVisual data coordinator.""" + +from __future__ import annotations + +from datetime import timedelta +from typing import Any + +from pyairvisual.cloud_api import ( + CloudAPI, + InvalidKeyError, + KeyExpiredError, + UnauthorizedError, +) +from pyairvisual.errors import AirVisualError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_COUNTRY, CONF_LATITUDE, CONF_LONGITUDE, CONF_STATE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_CITY, LOGGER + +type AirVisualConfigEntry = ConfigEntry[AirVisualDataUpdateCoordinator] + + +class AirVisualDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching AirVisual data.""" + + config_entry: AirVisualConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: AirVisualConfigEntry, + cloud_api: CloudAPI, + name: str, + ) -> None: + """Initialize the coordinator.""" + self._cloud_api = cloud_api + super().__init__( + hass, + LOGGER, + config_entry=entry, + name=name, + # We give a placeholder update interval in order to create the coordinator; + # then, in async_setup_entry, we use the coordinator's presence (along with + # any other coordinators using the same API key) to calculate an actual, + # leveled update interval: + update_interval=timedelta(minutes=5), + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Get new data from the API.""" + if CONF_CITY in self.config_entry.data: + api_coro = self._cloud_api.air_quality.city( + self.config_entry.data[CONF_CITY], + self.config_entry.data[CONF_STATE], + self.config_entry.data[CONF_COUNTRY], + ) + else: + api_coro = self._cloud_api.air_quality.nearest_city( + self.config_entry.data[CONF_LATITUDE], + self.config_entry.data[CONF_LONGITUDE], + ) + + try: + return await api_coro + except (InvalidKeyError, KeyExpiredError, UnauthorizedError) as ex: + raise ConfigEntryAuthFailed from ex + except AirVisualError as err: + raise UpdateFailed(f"Error while retrieving data: {err}") from err diff --git a/homeassistant/components/airvisual/diagnostics.py b/homeassistant/components/airvisual/diagnostics.py index 2e7c60364f9841..ff4f1d919c351b 100644 --- a/homeassistant/components/airvisual/diagnostics.py +++ b/homeassistant/components/airvisual/diagnostics.py @@ -15,8 +15,8 @@ ) from homeassistant.core import HomeAssistant -from . import AirVisualConfigEntry from .const import CONF_CITY +from .coordinator import AirVisualConfigEntry CONF_COORDINATES = "coordinates" CONF_TITLE = "title" diff --git a/homeassistant/components/airvisual/entity.py b/homeassistant/components/airvisual/entity.py index db480e560c761f..4bdec1d7f2ed11 100644 --- a/homeassistant/components/airvisual/entity.py +++ b/homeassistant/components/airvisual/entity.py @@ -2,29 +2,25 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .coordinator import AirVisualDataUpdateCoordinator -class AirVisualEntity(CoordinatorEntity): + +class AirVisualEntity(CoordinatorEntity[AirVisualDataUpdateCoordinator]): """Define a generic AirVisual entity.""" def __init__( self, - coordinator: DataUpdateCoordinator, - entry: ConfigEntry, + coordinator: AirVisualDataUpdateCoordinator, description: EntityDescription, ) -> None: """Initialize.""" super().__init__(coordinator) self._attr_extra_state_attributes = {} - self._entry = entry self.entity_description = description async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index 1f406bd8f3648b..929fbd7c886ac5 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -8,7 +8,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, @@ -24,10 +23,9 @@ ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import AirVisualConfigEntry from .const import CONF_CITY +from .coordinator import AirVisualConfigEntry, AirVisualDataUpdateCoordinator from .entity import AirVisualEntity ATTR_CITY = "city" @@ -113,7 +111,7 @@ async def async_setup_entry( """Set up AirVisual sensors based on a config entry.""" coordinator = entry.runtime_data async_add_entities( - AirVisualGeographySensor(coordinator, entry, description, locale) + AirVisualGeographySensor(coordinator, description, locale) for locale in GEOGRAPHY_SENSOR_LOCALES for description in GEOGRAPHY_SENSOR_DESCRIPTIONS ) @@ -124,14 +122,14 @@ class AirVisualGeographySensor(AirVisualEntity, SensorEntity): def __init__( self, - coordinator: DataUpdateCoordinator, - entry: ConfigEntry, + coordinator: AirVisualDataUpdateCoordinator, description: SensorEntityDescription, locale: str, ) -> None: """Initialize.""" - super().__init__(coordinator, entry, description) + super().__init__(coordinator, description) + entry = coordinator.config_entry self._attr_extra_state_attributes.update( { ATTR_CITY: entry.data.get(CONF_CITY), @@ -182,16 +180,16 @@ def update_from_latest_data(self) -> None: # # We use any coordinates in the config entry and, in the case of a geography by # name, we fall back to the latitude longitude provided in the coordinator data: - latitude = self._entry.data.get( + latitude = self.coordinator.config_entry.data.get( CONF_LATITUDE, self.coordinator.data["location"]["coordinates"][1], ) - longitude = self._entry.data.get( + longitude = self.coordinator.config_entry.data.get( CONF_LONGITUDE, self.coordinator.data["location"]["coordinates"][0], ) - if self._entry.options[CONF_SHOW_ON_MAP]: + if self.coordinator.config_entry.options[CONF_SHOW_ON_MAP]: self._attr_extra_state_attributes[ATTR_LATITUDE] = latitude self._attr_extra_state_attributes[ATTR_LONGITUDE] = longitude self._attr_extra_state_attributes.pop("lati", None) diff --git a/homeassistant/components/airvisual_pro/__init__.py b/homeassistant/components/airvisual_pro/__init__.py index 3b3ac6df232518..2c56086d399332 100644 --- a/homeassistant/components/airvisual_pro/__init__.py +++ b/homeassistant/components/airvisual_pro/__init__.py @@ -4,18 +4,9 @@ import asyncio from contextlib import suppress -from dataclasses import dataclass -from datetime import timedelta -from typing import Any - -from pyairvisual.node import ( - InvalidAuthenticationError, - NodeConnectionError, - NodeProError, - NodeSamba, -) -from homeassistant.config_entries import ConfigEntry +from pyairvisual.node import NodeProError, NodeSamba + from homeassistant.const import ( CONF_IP_ADDRESS, CONF_PASSWORD, @@ -23,25 +14,16 @@ Platform, ) from homeassistant.core import Event, HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.exceptions import ConfigEntryNotReady -from .const import LOGGER +from .coordinator import ( + AirVisualProConfigEntry, + AirVisualProCoordinator, + AirVisualProData, +) PLATFORMS = [Platform.SENSOR] -UPDATE_INTERVAL = timedelta(minutes=1) - -type AirVisualProConfigEntry = ConfigEntry[AirVisualProData] - - -@dataclass -class AirVisualProData: - """Define a data class.""" - - coordinator: DataUpdateCoordinator - node: NodeSamba - async def async_setup_entry( hass: HomeAssistant, entry: AirVisualProConfigEntry @@ -54,48 +36,15 @@ async def async_setup_entry( except NodeProError as err: raise ConfigEntryNotReady from err - reload_task: asyncio.Task | None = None - - async def async_get_data() -> dict[str, Any]: - """Get data from the device.""" - try: - data = await node.async_get_latest_measurements() - data["history"] = {} - if data["settings"].get("follow_mode") == "device": - history = await node.async_get_history(include_trends=False) - data["history"] = history.get("measurements", [])[-1] - except InvalidAuthenticationError as err: - raise ConfigEntryAuthFailed("Invalid Samba password") from err - except NodeConnectionError as err: - nonlocal reload_task - if not reload_task: - reload_task = hass.async_create_task( - hass.config_entries.async_reload(entry.entry_id) - ) - raise UpdateFailed(f"Connection to Pro unit lost: {err}") from err - except NodeProError as err: - raise UpdateFailed(f"Error while retrieving data: {err}") from err - - return data - - coordinator = DataUpdateCoordinator( - hass, - LOGGER, - config_entry=entry, - name="Node/Pro data", - update_interval=UPDATE_INTERVAL, - update_method=async_get_data, - ) - + coordinator = AirVisualProCoordinator(hass, entry, node) await coordinator.async_config_entry_first_refresh() entry.runtime_data = AirVisualProData(coordinator=coordinator, node=node) async def async_shutdown(_: Event) -> None: """Define an event handler to disconnect from the websocket.""" - nonlocal reload_task - if reload_task: + if coordinator.reload_task: with suppress(asyncio.CancelledError): - reload_task.cancel() + coordinator.reload_task.cancel() await node.async_disconnect() entry.async_on_unload( diff --git a/homeassistant/components/airvisual_pro/coordinator.py b/homeassistant/components/airvisual_pro/coordinator.py new file mode 100644 index 00000000000000..946a247ace14b5 --- /dev/null +++ b/homeassistant/components/airvisual_pro/coordinator.py @@ -0,0 +1,79 @@ +"""DataUpdateCoordinator for the AirVisual Pro integration.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from datetime import timedelta +from typing import Any + +from pyairvisual.node import ( + InvalidAuthenticationError, + NodeConnectionError, + NodeProError, + NodeSamba, +) + +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 LOGGER + +UPDATE_INTERVAL = timedelta(minutes=1) + + +@dataclass +class AirVisualProData: + """Define a data class.""" + + coordinator: AirVisualProCoordinator + node: NodeSamba + + +type AirVisualProConfigEntry = ConfigEntry[AirVisualProData] + + +class AirVisualProCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Coordinator for AirVisual Pro data.""" + + config_entry: AirVisualProConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: AirVisualProConfigEntry, + node: NodeSamba, + ) -> None: + """Initialize.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name="Node/Pro data", + update_interval=UPDATE_INTERVAL, + ) + self._node = node + self.reload_task: asyncio.Task[bool] | None = None + + async def _async_update_data(self) -> dict[str, Any]: + """Get data from the device.""" + try: + data = await self._node.async_get_latest_measurements() + data["history"] = {} + if data["settings"].get("follow_mode") == "device": + history = await self._node.async_get_history(include_trends=False) + data["history"] = history.get("measurements", [])[-1] + except InvalidAuthenticationError as err: + raise ConfigEntryAuthFailed("Invalid Samba password") from err + except NodeConnectionError as err: + if self.reload_task is None: + self.reload_task = self.hass.async_create_task( + self.hass.config_entries.async_reload(self.config_entry.entry_id) + ) + raise UpdateFailed(f"Connection to Pro unit lost: {err}") from err + except NodeProError as err: + raise UpdateFailed(f"Error while retrieving data: {err}") from err + + return data diff --git a/homeassistant/components/airvisual_pro/diagnostics.py b/homeassistant/components/airvisual_pro/diagnostics.py index da8714425471fe..dc69483c78f29b 100644 --- a/homeassistant/components/airvisual_pro/diagnostics.py +++ b/homeassistant/components/airvisual_pro/diagnostics.py @@ -8,7 +8,7 @@ from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant -from . import AirVisualProConfigEntry +from .coordinator import AirVisualProConfigEntry CONF_MAC_ADDRESS = "mac_address" CONF_SERIAL_NUMBER = "serial_number" diff --git a/homeassistant/components/airvisual_pro/entity.py b/homeassistant/components/airvisual_pro/entity.py index bc28fa36e52424..b44c5ed8bceb6e 100644 --- a/homeassistant/components/airvisual_pro/entity.py +++ b/homeassistant/components/airvisual_pro/entity.py @@ -4,19 +4,17 @@ from homeassistant.helpers.device_registry import 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 AirVisualProCoordinator -class AirVisualProEntity(CoordinatorEntity): +class AirVisualProEntity(CoordinatorEntity[AirVisualProCoordinator]): """Define a generic AirVisual Pro entity.""" def __init__( - self, coordinator: DataUpdateCoordinator, description: EntityDescription + self, coordinator: AirVisualProCoordinator, description: EntityDescription ) -> None: """Initialize.""" super().__init__(coordinator) diff --git a/homeassistant/components/airvisual_pro/sensor.py b/homeassistant/components/airvisual_pro/sensor.py index 215370736fe60a..3fac272e655c28 100644 --- a/homeassistant/components/airvisual_pro/sensor.py +++ b/homeassistant/components/airvisual_pro/sensor.py @@ -22,7 +22,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import AirVisualProConfigEntry +from .coordinator import AirVisualProConfigEntry from .entity import AirVisualProEntity diff --git a/homeassistant/components/aladdin_connect/__init__.py b/homeassistant/components/aladdin_connect/__init__.py index 48bedafdd1ab8f..25e5426d23c476 100644 --- a/homeassistant/components/aladdin_connect/__init__.py +++ b/homeassistant/components/aladdin_connect/__init__.py @@ -2,10 +2,12 @@ from __future__ import annotations +import aiohttp from genie_partner_sdk.client import AladdinConnectClient from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( aiohttp_client, config_entry_oauth2_flow, @@ -31,16 +33,23 @@ async def async_setup_entry( session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + try: + await session.async_ensure_token_valid() + except aiohttp.ClientResponseError as err: + if 400 <= err.status < 500: + raise ConfigEntryAuthFailed(err) from err + raise ConfigEntryNotReady from err + except aiohttp.ClientError as err: + raise ConfigEntryNotReady from err + client = AladdinConnectClient( api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session) ) - doors = await client.get_doors() + coordinator = AladdinConnectCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() - entry.runtime_data = { - door.unique_id: AladdinConnectCoordinator(hass, entry, client, door) - for door in doors - } + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -82,7 +91,7 @@ def remove_stale_devices( device_entries = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) - all_device_ids = set(config_entry.runtime_data) + all_device_ids = set(config_entry.runtime_data.data) for device_entry in device_entries: device_id: str | None = None diff --git a/homeassistant/components/aladdin_connect/api.py b/homeassistant/components/aladdin_connect/api.py index ea46bf69f4a238..481aa06be6541c 100644 --- a/homeassistant/components/aladdin_connect/api.py +++ b/homeassistant/components/aladdin_connect/api.py @@ -11,6 +11,18 @@ API_KEY = "k6QaiQmcTm2zfaNns5L1Z8duBtJmhDOW8JawlCC3" +class AsyncConfigFlowAuth(Auth): + """Provide Aladdin Connect Genie authentication for config flow validation.""" + + def __init__(self, websession: ClientSession, access_token: str) -> None: + """Initialize Aladdin Connect Genie auth.""" + super().__init__(websession, API_URL, access_token, API_KEY) + + async def async_get_access_token(self) -> str: + """Return the access token.""" + return self.access_token + + class AsyncConfigEntryAuth(Auth): """Provide Aladdin Connect Genie authentication tied to an OAuth2 based config entry.""" diff --git a/homeassistant/components/aladdin_connect/config_flow.py b/homeassistant/components/aladdin_connect/config_flow.py index dab801d4712227..66aa67ffd01495 100644 --- a/homeassistant/components/aladdin_connect/config_flow.py +++ b/homeassistant/components/aladdin_connect/config_flow.py @@ -4,12 +4,14 @@ import logging from typing import Any +from genie_partner_sdk.client import AladdinConnectClient import jwt import voluptuous as vol from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow +from .api import AsyncConfigFlowAuth from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN @@ -52,11 +54,25 @@ async def async_step_reauth_confirm( async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: """Create an oauth config entry or update existing entry for reauth.""" - # Extract the user ID from the JWT token's 'sub' field - token = jwt.decode( - data["token"]["access_token"], options={"verify_signature": False} + try: + token = jwt.decode( + data["token"]["access_token"], options={"verify_signature": False} + ) + user_id = token["sub"] + except jwt.DecodeError, KeyError: + return self.async_abort(reason="oauth_error") + + client = AladdinConnectClient( + AsyncConfigFlowAuth( + aiohttp_client.async_get_clientsession(self.hass), + data["token"]["access_token"], + ) ) - user_id = token["sub"] + try: + await client.get_doors() + except Exception: # noqa: BLE001 + return self.async_abort(reason="cannot_connect") + await self.async_set_unique_id(user_id) if self.source == SOURCE_REAUTH: diff --git a/homeassistant/components/aladdin_connect/coordinator.py b/homeassistant/components/aladdin_connect/coordinator.py index 718aed8e44572c..c18ef8e0bbfb3b 100644 --- a/homeassistant/components/aladdin_connect/coordinator.py +++ b/homeassistant/components/aladdin_connect/coordinator.py @@ -5,27 +5,30 @@ from datetime import timedelta import logging +import aiohttp from genie_partner_sdk.client import AladdinConnectClient from genie_partner_sdk.model import GarageDoor from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed _LOGGER = logging.getLogger(__name__) -type AladdinConnectConfigEntry = ConfigEntry[dict[str, AladdinConnectCoordinator]] +type AladdinConnectConfigEntry = ConfigEntry[AladdinConnectCoordinator] SCAN_INTERVAL = timedelta(seconds=15) -class AladdinConnectCoordinator(DataUpdateCoordinator[GarageDoor]): +class AladdinConnectCoordinator(DataUpdateCoordinator[dict[str, GarageDoor]]): """Coordinator for Aladdin Connect integration.""" + config_entry: AladdinConnectConfigEntry + def __init__( self, hass: HomeAssistant, entry: AladdinConnectConfigEntry, client: AladdinConnectClient, - garage_door: GarageDoor, ) -> None: """Initialize the coordinator.""" super().__init__( @@ -36,15 +39,16 @@ def __init__( update_interval=SCAN_INTERVAL, ) self.client = client - self.data = garage_door - async def _async_update_data(self) -> GarageDoor: + async def _async_update_data(self) -> dict[str, GarageDoor]: """Fetch data from the Aladdin Connect API.""" - await self.client.update_door(self.data.device_id, self.data.door_number) - self.data.status = self.client.get_door_status( - self.data.device_id, self.data.door_number - ) - self.data.battery_level = self.client.get_battery_status( - self.data.device_id, self.data.door_number - ) - return self.data + try: + doors = await self.client.get_doors() + except aiohttp.ClientResponseError as err: + if 400 <= err.status < 500: + raise ConfigEntryAuthFailed(err) from err + raise UpdateFailed(f"Error communicating with API: {err}") from err + except aiohttp.ClientError as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + return {door.unique_id: door for door in doors} diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 4bc787539fd9d2..e6c5d0457b5114 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -4,14 +4,19 @@ from typing import Any +import aiohttp + from homeassistant.components.cover import CoverDeviceClass, CoverEntity -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import SUPPORTED_FEATURES +from .const import DOMAIN, SUPPORTED_FEATURES from .coordinator import AladdinConnectConfigEntry, AladdinConnectCoordinator from .entity import AladdinConnectEntity +PARALLEL_UPDATES = 1 + async def async_setup_entry( hass: HomeAssistant, @@ -19,11 +24,22 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the cover platform.""" - coordinators = entry.runtime_data + coordinator = entry.runtime_data + known_devices: set[str] = set() + + @callback + def _async_add_new_devices() -> None: + """Detect and add entities for new doors.""" + current_devices = set(coordinator.data) + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + AladdinCoverEntity(coordinator, door_id) for door_id in new_devices + ) - async_add_entities( - AladdinCoverEntity(coordinator) for coordinator in coordinators.values() - ) + _async_add_new_devices() + entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices)) class AladdinCoverEntity(AladdinConnectEntity, CoverEntity): @@ -33,32 +49,44 @@ class AladdinCoverEntity(AladdinConnectEntity, CoverEntity): _attr_supported_features = SUPPORTED_FEATURES _attr_name = None - def __init__(self, coordinator: AladdinConnectCoordinator) -> None: + def __init__(self, coordinator: AladdinConnectCoordinator, door_id: str) -> None: """Initialize the Aladdin Connect cover.""" - super().__init__(coordinator) - self._attr_unique_id = coordinator.data.unique_id + super().__init__(coordinator, door_id) + self._attr_unique_id = door_id async def async_open_cover(self, **kwargs: Any) -> None: """Issue open command to cover.""" - await self.client.open_door(self._device_id, self._number) + try: + await self.client.open_door(self._device_id, self._number) + except aiohttp.ClientError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="open_door_failed", + ) from err async def async_close_cover(self, **kwargs: Any) -> None: """Issue close command to cover.""" - await self.client.close_door(self._device_id, self._number) + try: + await self.client.close_door(self._device_id, self._number) + except aiohttp.ClientError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="close_door_failed", + ) from err @property def is_closed(self) -> bool | None: """Update is closed attribute.""" - if (status := self.coordinator.data.status) is None: + if (status := self.door.status) is None: return None return status == "closed" @property def is_closing(self) -> bool | None: """Update is closing attribute.""" - return self.coordinator.data.status == "closing" + return self.door.status == "closing" @property def is_opening(self) -> bool | None: """Update is opening attribute.""" - return self.coordinator.data.status == "opening" + return self.door.status == "opening" diff --git a/homeassistant/components/aladdin_connect/diagnostics.py b/homeassistant/components/aladdin_connect/diagnostics.py new file mode 100644 index 00000000000000..583141bbca0bbc --- /dev/null +++ b/homeassistant/components/aladdin_connect/diagnostics.py @@ -0,0 +1,32 @@ +"""Diagnostics support for Aladdin Connect.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from .coordinator import AladdinConnectConfigEntry + +TO_REDACT = {"access_token", "refresh_token"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: AladdinConnectConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return { + "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT), + "doors": { + uid: { + "device_id": door.device_id, + "door_number": door.door_number, + "name": door.name, + "status": door.status, + "link_status": door.link_status, + "battery_level": door.battery_level, + } + for uid, door in config_entry.runtime_data.data.items() + }, + } diff --git a/homeassistant/components/aladdin_connect/entity.py b/homeassistant/components/aladdin_connect/entity.py index 39a38fbd1ca2b2..eff536a6b1fa85 100644 --- a/homeassistant/components/aladdin_connect/entity.py +++ b/homeassistant/components/aladdin_connect/entity.py @@ -1,6 +1,7 @@ """Base class for Aladdin Connect entities.""" from genie_partner_sdk.client import AladdinConnectClient +from genie_partner_sdk.model import GarageDoor from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -14,17 +15,28 @@ class AladdinConnectEntity(CoordinatorEntity[AladdinConnectCoordinator]): _attr_has_entity_name = True - def __init__(self, coordinator: AladdinConnectCoordinator) -> None: + def __init__(self, coordinator: AladdinConnectCoordinator, door_id: str) -> None: """Initialize Aladdin Connect entity.""" super().__init__(coordinator) - device = coordinator.data + self._door_id = door_id + door = self.door self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device.unique_id)}, + identifiers={(DOMAIN, door.unique_id)}, manufacturer="Aladdin Connect", - name=device.name, + name=door.name, ) - self._device_id = device.device_id - self._number = device.door_number + self._device_id = door.device_id + self._number = door.door_number + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self._door_id in self.coordinator.data + + @property + def door(self) -> GarageDoor: + """Return the garage door data.""" + return self.coordinator.data[self._door_id] @property def client(self) -> AladdinConnectClient: diff --git a/homeassistant/components/aladdin_connect/quality_scale.yaml b/homeassistant/components/aladdin_connect/quality_scale.yaml index 88d454a55320ba..715ed2c3c06cbb 100644 --- a/homeassistant/components/aladdin_connect/quality_scale.yaml +++ b/homeassistant/components/aladdin_connect/quality_scale.yaml @@ -7,75 +7,57 @@ rules: brands: done common-modules: done config-flow: done - config-flow-test-coverage: todo + config-flow-test-coverage: done dependency-transparency: done docs-actions: status: exempt comment: Integration does not register any service actions. docs-high-level-description: done - docs-installation-instructions: - status: todo - comment: Documentation needs to be created. - docs-removal-instructions: - status: todo - comment: Documentation needs to be created. + docs-installation-instructions: done + docs-removal-instructions: done entity-event-setup: status: exempt comment: Integration does not subscribe to external events. entity-unique-id: done has-entity-name: done runtime-data: done - test-before-configure: - status: todo - comment: Config flow does not currently test connection during setup. - test-before-setup: todo + test-before-configure: done + test-before-setup: done unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: - status: todo - comment: Documentation needs to be created. - docs-installation-parameters: - status: todo - comment: Documentation needs to be created. - entity-unavailable: todo + status: exempt + comment: Integration does not have an options flow. + docs-installation-parameters: done + entity-unavailable: + status: done + comment: Handled by the coordinator. integration-owner: done - log-when-unavailable: todo - parallel-updates: todo + log-when-unavailable: + status: done + comment: Handled by the coordinator. + parallel-updates: done reauthentication-flow: done - test-coverage: - status: todo - comment: Platform tests for cover and sensor need to be implemented to reach 95% coverage. + test-coverage: done # Gold devices: done - diagnostics: todo - discovery: todo - discovery-update-info: todo - docs-data-update: - status: todo - comment: Documentation needs to be created. - docs-examples: - status: todo - comment: Documentation needs to be created. - docs-known-limitations: - status: todo - comment: Documentation needs to be created. - docs-supported-devices: - status: todo - comment: Documentation needs to be created. - docs-supported-functions: - status: todo - comment: Documentation needs to be created. - docs-troubleshooting: - status: todo - comment: Documentation needs to be created. - docs-use-cases: - status: todo - comment: Documentation needs to be created. - dynamic-devices: todo + diagnostics: done + discovery: done + discovery-update-info: + status: exempt + comment: Integration connects via the cloud and not locally. + 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 @@ -84,9 +66,7 @@ rules: icon-translations: todo reconfiguration-flow: todo repair-issues: todo - stale-devices: - status: todo - comment: Stale devices can be done dynamically + stale-devices: done # Platinum async-dependency: todo diff --git a/homeassistant/components/aladdin_connect/sensor.py b/homeassistant/components/aladdin_connect/sensor.py index d327a138244164..45943327ad42b1 100644 --- a/homeassistant/components/aladdin_connect/sensor.py +++ b/homeassistant/components/aladdin_connect/sensor.py @@ -14,12 +14,14 @@ SensorStateClass, ) from homeassistant.const import PERCENTAGE, EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AladdinConnectConfigEntry, AladdinConnectCoordinator from .entity import AladdinConnectEntity +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class AladdinConnectSensorEntityDescription(SensorEntityDescription): @@ -47,13 +49,24 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Aladdin Connect sensor devices.""" - coordinators = entry.runtime_data - - async_add_entities( - AladdinConnectSensor(coordinator, description) - for coordinator in coordinators.values() - for description in SENSOR_TYPES - ) + coordinator = entry.runtime_data + known_devices: set[str] = set() + + @callback + def _async_add_new_devices() -> None: + """Detect and add entities for new doors.""" + current_devices = set(coordinator.data) + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + AladdinConnectSensor(coordinator, door_id, description) + for door_id in new_devices + for description in SENSOR_TYPES + ) + + _async_add_new_devices() + entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices)) class AladdinConnectSensor(AladdinConnectEntity, SensorEntity): @@ -64,14 +77,15 @@ class AladdinConnectSensor(AladdinConnectEntity, SensorEntity): def __init__( self, coordinator: AladdinConnectCoordinator, + door_id: str, entity_description: AladdinConnectSensorEntityDescription, ) -> None: """Initialize the Aladdin Connect sensor.""" - super().__init__(coordinator) + super().__init__(coordinator, door_id) self.entity_description = entity_description - self._attr_unique_id = f"{coordinator.data.unique_id}-{entity_description.key}" + self._attr_unique_id = f"{door_id}-{entity_description.key}" @property def native_value(self) -> float | None: """Return the state of the sensor.""" - return self.entity_description.value_fn(self.coordinator.data) + return self.entity_description.value_fn(self.door) diff --git a/homeassistant/components/aladdin_connect/strings.json b/homeassistant/components/aladdin_connect/strings.json index bac173a5632244..a04552108a2007 100644 --- a/homeassistant/components/aladdin_connect/strings.json +++ b/homeassistant/components/aladdin_connect/strings.json @@ -4,6 +4,7 @@ "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%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cloud_not_enabled": "Please make sure you run Home Assistant with `{default_config}` enabled in your configuration.yaml.", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", @@ -31,5 +32,13 @@ "title": "[%key:common::config_flow::title::reauth%]" } } + }, + "exceptions": { + "close_door_failed": { + "message": "Failed to close the garage door" + }, + "open_door_failed": { + "message": "Failed to open the garage door" + } } } diff --git a/homeassistant/components/alarm_control_panel/condition.py b/homeassistant/components/alarm_control_panel/condition.py index b1d3da3488b6e2..59603a25ce2627 100644 --- a/homeassistant/components/alarm_control_panel/condition.py +++ b/homeassistant/components/alarm_control_panel/condition.py @@ -2,6 +2,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.condition import ( Condition, EntityStateConditionBase, @@ -43,7 +44,7 @@ def make_entity_state_required_features_condition( class CustomCondition(EntityStateRequiredFeaturesCondition): """Condition for entity state changes.""" - _domain = domain + _domain_specs = {domain: DomainSpec()} _states = {to_state} _required_features = required_features diff --git a/homeassistant/components/alarm_control_panel/trigger.py b/homeassistant/components/alarm_control_panel/trigger.py index d970ea9ec6bb7b..22aa8b6fc0578a 100644 --- a/homeassistant/components/alarm_control_panel/trigger.py +++ b/homeassistant/components/alarm_control_panel/trigger.py @@ -2,6 +2,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.trigger import ( EntityTargetStateTriggerBase, @@ -44,7 +45,7 @@ def make_entity_state_trigger_required_features( class CustomTrigger(EntityStateTriggerRequiredFeatures): """Trigger for entity state changes.""" - _domain = domain + _domain_specs = {domain: DomainSpec()} _to_states = {to_state} _required_features = required_features diff --git a/homeassistant/components/alarmdecoder/services.py b/homeassistant/components/alarmdecoder/services.py index 98a58239265aa8..d9d5002ca947be 100644 --- a/homeassistant/components/alarmdecoder/services.py +++ b/homeassistant/components/alarmdecoder/services.py @@ -13,9 +13,6 @@ from .const import DOMAIN -SERVICE_ALARM_TOGGLE_CHIME = "alarm_toggle_chime" - -SERVICE_ALARM_KEYPRESS = "alarm_keypress" ATTR_KEYPRESS = "keypress" @@ -26,7 +23,7 @@ def async_setup_services(hass: HomeAssistant) -> None: service.async_register_platform_entity_service( hass, DOMAIN, - SERVICE_ALARM_TOGGLE_CHIME, + "alarm_toggle_chime", entity_domain=ALARM_CONTROL_PANEL_DOMAIN, schema={ vol.Required(ATTR_CODE): cv.string, @@ -37,7 +34,7 @@ def async_setup_services(hass: HomeAssistant) -> None: service.async_register_platform_entity_service( hass, DOMAIN, - SERVICE_ALARM_KEYPRESS, + "alarm_keypress", entity_domain=ALARM_CONTROL_PANEL_DOMAIN, schema={ vol.Required(ATTR_KEYPRESS): cv.string, diff --git a/homeassistant/components/alert/entity.py b/homeassistant/components/alert/entity.py index f4497e0f7ad725..a7f9f50f61e226 100644 --- a/homeassistant/components/alert/entity.py +++ b/homeassistant/components/alert/entity.py @@ -13,7 +13,7 @@ ATTR_DATA, ATTR_MESSAGE, ATTR_TITLE, - DOMAIN as DOMAIN_NOTIFY, + DOMAIN as NOTIFY_DOMAIN, ) from homeassistant.const import STATE_IDLE, STATE_OFF, STATE_ON from homeassistant.core import Event, EventStateChangedData, HassJob, HomeAssistant @@ -185,7 +185,7 @@ async def _send_notification_message(self, message: Any) -> None: for target in self._notifiers: try: await self.hass.services.async_call( - DOMAIN_NOTIFY, target, msg_payload, context=self._context + NOTIFY_DOMAIN, target, msg_payload, context=self._context ) except ServiceNotFound: LOGGER.error( diff --git a/homeassistant/components/alexa_devices/entity.py b/homeassistant/components/alexa_devices/entity.py index bb3ae900b0998a..21b01e26f6ccb8 100644 --- a/homeassistant/components/alexa_devices/entity.py +++ b/homeassistant/components/alexa_devices/entity.py @@ -1,6 +1,5 @@ """Defines a base Alexa Devices entity.""" -from aioamazondevices.const.devices import SPEAKER_GROUP_MODEL from aioamazondevices.structures import AmazonDevice from homeassistant.helpers.device_registry import DeviceInfo @@ -25,19 +24,15 @@ def __init__( """Initialize the entity.""" super().__init__(coordinator) self._serial_num = serial_num - model_details = coordinator.api.get_model_details(self.device) or {} - model = model_details.get("model") self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, serial_num)}, name=self.device.account_name, - model=model, + model=self.device.model, model_id=self.device.device_type, - manufacturer=model_details.get("manufacturer", "Amazon"), - hw_version=model_details.get("hw_version"), - sw_version=( - self.device.software_version if model != SPEAKER_GROUP_MODEL else None - ), - serial_number=serial_num if model != SPEAKER_GROUP_MODEL else None, + manufacturer=self.device.manufacturer or "Amazon", + hw_version=self.device.hardware_version, + sw_version=self.device.software_version, + serial_number=serial_num, ) self.entity_description = description self._attr_unique_id = f"{serial_num}-{description.key}" diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index adcb6325f1a7bf..fb3f2e9c15fdcd 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "platinum", - "requirements": ["aioamazondevices==12.0.0"] + "requirements": ["aioamazondevices==13.0.1"] } diff --git a/homeassistant/components/alexa_devices/services.py b/homeassistant/components/alexa_devices/services.py index fb0fda0b84346e..06beb5258f3ed8 100644 --- a/homeassistant/components/alexa_devices/services.py +++ b/homeassistant/components/alexa_devices/services.py @@ -16,9 +16,6 @@ ATTR_TEXT_COMMAND = "text_command" ATTR_SOUND = "sound" ATTR_INFO_SKILL = "info_skill" -SERVICE_TEXT_COMMAND = "send_text_command" -SERVICE_SOUND_NOTIFICATION = "send_sound" -SERVICE_INFO_SKILL = "send_info_skill" SCHEMA_SOUND_SERVICE = vol.Schema( { @@ -128,17 +125,17 @@ def async_setup_services(hass: HomeAssistant) -> None: """Set up the services for the Amazon Devices integration.""" for service_name, method, schema in ( ( - SERVICE_SOUND_NOTIFICATION, + "send_sound", async_send_sound_notification, SCHEMA_SOUND_SERVICE, ), ( - SERVICE_TEXT_COMMAND, + "send_text_command", async_send_text_command, SCHEMA_CUSTOM_COMMAND, ), ( - SERVICE_INFO_SKILL, + "send_info_skill", async_send_info_skill, SCHEMA_INFO_SKILL, ), diff --git a/homeassistant/components/alexa_devices/switch.py b/homeassistant/components/alexa_devices/switch.py index acc076c7993472..7c033834b0d63e 100644 --- a/homeassistant/components/alexa_devices/switch.py +++ b/homeassistant/components/alexa_devices/switch.py @@ -101,7 +101,10 @@ async def _switch_set_state(self, state: bool) -> None: assert method is not None await method(self.device, state) - await self.coordinator.async_request_refresh() + self.coordinator.data[self.device.serial_number].sensors[ + self.entity_description.key + ].value = state + self.async_write_ha_state() async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" diff --git a/homeassistant/components/amberelectric/const.py b/homeassistant/components/amberelectric/const.py index 3a1dbc9023a996..cfe840dcde82e8 100644 --- a/homeassistant/components/amberelectric/const.py +++ b/homeassistant/components/amberelectric/const.py @@ -16,8 +16,6 @@ LOGGER = logging.getLogger(__package__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -SERVICE_GET_FORECASTS = "get_forecasts" - GENERAL_CHANNEL = "general" CONTROLLED_LOAD_CHANNEL = "controlled_load" FEED_IN_CHANNEL = "feed_in" diff --git a/homeassistant/components/amberelectric/services.py b/homeassistant/components/amberelectric/services.py index c4549498b91324..f936d4a3d3c2b7 100644 --- a/homeassistant/components/amberelectric/services.py +++ b/homeassistant/components/amberelectric/services.py @@ -22,7 +22,6 @@ DOMAIN, FEED_IN_CHANNEL, GENERAL_CHANNEL, - SERVICE_GET_FORECASTS, ) from .coordinator import AmberConfigEntry from .helpers import format_cents_to_dollars, normalize_descriptor @@ -101,7 +100,7 @@ async def handle_get_forecasts(call: ServiceCall) -> ServiceResponse: hass.services.async_register( DOMAIN, - SERVICE_GET_FORECASTS, + "get_forecasts", handle_get_forecasts, GET_FORECASTS_SCHEMA, supports_response=SupportsResponse.ONLY, diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index 0bf02b604f1dbb..5c3655e8d3115c 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -49,18 +49,6 @@ STREAM_SOURCE_LIST = ["snapshot", "mjpeg", "rtsp"] -_SRV_EN_REC = "enable_recording" -_SRV_DS_REC = "disable_recording" -_SRV_EN_AUD = "enable_audio" -_SRV_DS_AUD = "disable_audio" -_SRV_EN_MOT_REC = "enable_motion_recording" -_SRV_DS_MOT_REC = "disable_motion_recording" -_SRV_GOTO = "goto_preset" -_SRV_CBW = "set_color_bw" -_SRV_TOUR_ON = "start_tour" -_SRV_TOUR_OFF = "stop_tour" - -_SRV_PTZ_CTRL = "ptz_control" _ATTR_PTZ_TT = "travel_time" _ATTR_PTZ_MOV = "movement" _MOV = [ @@ -103,17 +91,17 @@ ) CAMERA_SERVICES = { - _SRV_EN_REC: (_SRV_SCHEMA, "async_enable_recording", ()), - _SRV_DS_REC: (_SRV_SCHEMA, "async_disable_recording", ()), - _SRV_EN_AUD: (_SRV_SCHEMA, "async_enable_audio", ()), - _SRV_DS_AUD: (_SRV_SCHEMA, "async_disable_audio", ()), - _SRV_EN_MOT_REC: (_SRV_SCHEMA, "async_enable_motion_recording", ()), - _SRV_DS_MOT_REC: (_SRV_SCHEMA, "async_disable_motion_recording", ()), - _SRV_GOTO: (_SRV_GOTO_SCHEMA, "async_goto_preset", (_ATTR_PRESET,)), - _SRV_CBW: (_SRV_CBW_SCHEMA, "async_set_color_bw", (_ATTR_COLOR_BW,)), - _SRV_TOUR_ON: (_SRV_SCHEMA, "async_start_tour", ()), - _SRV_TOUR_OFF: (_SRV_SCHEMA, "async_stop_tour", ()), - _SRV_PTZ_CTRL: ( + "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), diff --git a/homeassistant/components/amcrest/strings.json b/homeassistant/components/amcrest/strings.json index 3071b249dc2df9..20d576d362f582 100644 --- a/homeassistant/components/amcrest/strings.json +++ b/homeassistant/components/amcrest/strings.json @@ -75,7 +75,7 @@ "name": "Go to preset" }, "ptz_control": { - "description": "Moves (pan/tilt) and/or zoom a PTZ camera.", + "description": "Moves (pan/tilt) and/or zooms a PTZ camera.", "fields": { "entity_id": { "description": "[%key:component::amcrest::services::enable_recording::fields::entity_id::description%]", diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 7778e3239abce7..af479587d4f696 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -338,6 +338,7 @@ async def send_analytics(self, _: datetime | None = None) -> None: hass = self._hass supervisor_info = None + addons_info: dict[str, Any] | None = None operating_system_info: dict[str, Any] = {} if self._data.uuid is None: @@ -347,6 +348,7 @@ async def send_analytics(self, _: datetime | None = None) -> None: if self.supervisor: supervisor_info = hassio.get_supervisor_info(hass) operating_system_info = hassio.get_os_info(hass) or {} + addons_info = hassio.get_addons_info(hass) or {} system_info = await async_get_system_info(hass) integrations = [] @@ -419,13 +421,10 @@ async def send_analytics(self, _: datetime | None = None) -> None: integrations.append(integration.domain) - if supervisor_info is not None: + if addons_info is not None: supervisor_client = hassio.get_supervisor_client(hass) installed_addons = await asyncio.gather( - *( - supervisor_client.addons.addon_info(addon[ATTR_SLUG]) - for addon in supervisor_info[ATTR_ADDONS] - ) + *(supervisor_client.addons.addon_info(slug) for slug in addons_info) ) addons.extend( { @@ -534,6 +533,10 @@ async def send_snapshot(self, _: datetime | None = None) -> None: payload = await _async_snapshot_payload(self._hass) + if not payload: + LOGGER.info("Skipping snapshot submission, no data to send") + return + headers = { "Content-Type": "application/json", "User-Agent": f"home-assistant/{HA_VERSION}", diff --git a/homeassistant/components/analytics_insights/sensor.py b/homeassistant/components/analytics_insights/sensor.py index 8664e8388848eb..d5a64e93b0ad3e 100644 --- a/homeassistant/components/analytics_insights/sensor.py +++ b/homeassistant/components/analytics_insights/sensor.py @@ -38,7 +38,6 @@ def get_app_entity_description( translation_key="apps", name=name_slug, state_class=SensorStateClass.TOTAL, - native_unit_of_measurement="active installations", value_fn=lambda data: data.apps.get(name_slug), ) @@ -52,7 +51,6 @@ def get_core_integration_entity_description( translation_key="core_integrations", name=name, state_class=SensorStateClass.TOTAL, - native_unit_of_measurement="active installations", value_fn=lambda data: data.core_integrations.get(domain), ) @@ -66,7 +64,6 @@ def get_custom_integration_entity_description( translation_key="custom_integrations", translation_placeholders={"custom_integration_domain": domain}, state_class=SensorStateClass.TOTAL, - native_unit_of_measurement="active installations", value_fn=lambda data: data.custom_integrations.get(domain), ) @@ -77,7 +74,6 @@ def get_custom_integration_entity_description( translation_key="total_active_installations", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL, - native_unit_of_measurement="active installations", value_fn=lambda data: data.active_installations, ), AnalyticsSensorEntityDescription( @@ -85,7 +81,6 @@ def get_custom_integration_entity_description( translation_key="total_reports_integrations", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL, - native_unit_of_measurement="active installations", value_fn=lambda data: data.reports_integrations, ), ] diff --git a/homeassistant/components/analytics_insights/strings.json b/homeassistant/components/analytics_insights/strings.json index b5c4307cf8ff08..e01c8bdfd311a9 100644 --- a/homeassistant/components/analytics_insights/strings.json +++ b/homeassistant/components/analytics_insights/strings.json @@ -24,14 +24,23 @@ }, "entity": { "sensor": { + "apps": { + "unit_of_measurement": "active installations" + }, + "core_integrations": { + "unit_of_measurement": "[%key:component::analytics_insights::entity::sensor::apps::unit_of_measurement%]" + }, "custom_integrations": { - "name": "{custom_integration_domain} (custom)" + "name": "{custom_integration_domain} (custom)", + "unit_of_measurement": "[%key:component::analytics_insights::entity::sensor::apps::unit_of_measurement%]" }, "total_active_installations": { - "name": "Total active installations" + "name": "Total active installations", + "unit_of_measurement": "[%key:component::analytics_insights::entity::sensor::apps::unit_of_measurement%]" }, "total_reports_integrations": { - "name": "Total reported integrations" + "name": "Total reported integrations", + "unit_of_measurement": "[%key:component::analytics_insights::entity::sensor::apps::unit_of_measurement%]" } } }, diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 9621282208e1e6..57a45798364e83 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -36,7 +36,7 @@ SIGNAL_CONFIG_ENTITY, ) from .entity import AndroidTVEntity, adb_decorator -from .services import ATTR_ADB_RESPONSE, ATTR_HDMI_INPUT, SERVICE_LEARN_SENDEVENT +from .services import ATTR_ADB_RESPONSE, ATTR_HDMI_INPUT _LOGGER = logging.getLogger(__name__) @@ -271,7 +271,7 @@ async def learn_sendevent(self) -> None: self.async_write_ha_state() msg = ( - f"Output from service '{SERVICE_LEARN_SENDEVENT}' from" + f"Output from service 'learn_sendevent' from" f" {self.entity_id}: '{output}'" ) persistent_notification.async_create( diff --git a/homeassistant/components/androidtv/services.py b/homeassistant/components/androidtv/services.py index 8a44399b727468..895f9d334ce73d 100644 --- a/homeassistant/components/androidtv/services.py +++ b/homeassistant/components/androidtv/services.py @@ -16,11 +16,6 @@ ATTR_HDMI_INPUT = "hdmi_input" ATTR_LOCAL_PATH = "local_path" -SERVICE_ADB_COMMAND = "adb_command" -SERVICE_DOWNLOAD = "download" -SERVICE_LEARN_SENDEVENT = "learn_sendevent" -SERVICE_UPLOAD = "upload" - @callback def async_setup_services(hass: HomeAssistant) -> None: @@ -29,7 +24,7 @@ def async_setup_services(hass: HomeAssistant) -> None: service.async_register_platform_entity_service( hass, DOMAIN, - SERVICE_ADB_COMMAND, + "adb_command", entity_domain=MEDIA_PLAYER_DOMAIN, schema={vol.Required(ATTR_COMMAND): cv.string}, func="adb_command", @@ -37,7 +32,7 @@ def async_setup_services(hass: HomeAssistant) -> None: service.async_register_platform_entity_service( hass, DOMAIN, - SERVICE_LEARN_SENDEVENT, + "learn_sendevent", entity_domain=MEDIA_PLAYER_DOMAIN, schema=None, func="learn_sendevent", @@ -45,7 +40,7 @@ def async_setup_services(hass: HomeAssistant) -> None: service.async_register_platform_entity_service( hass, DOMAIN, - SERVICE_DOWNLOAD, + "download", entity_domain=MEDIA_PLAYER_DOMAIN, schema={ vol.Required(ATTR_DEVICE_PATH): cv.string, @@ -56,7 +51,7 @@ def async_setup_services(hass: HomeAssistant) -> None: service.async_register_platform_entity_service( hass, DOMAIN, - SERVICE_UPLOAD, + "upload", entity_domain=MEDIA_PLAYER_DOMAIN, schema={ vol.Required(ATTR_DEVICE_PATH): cv.string, diff --git a/homeassistant/components/androidtv_remote/helpers.py b/homeassistant/components/androidtv_remote/helpers.py index 9052a414393765..c267677f1f7afe 100644 --- a/homeassistant/components/androidtv_remote/helpers.py +++ b/homeassistant/components/androidtv_remote/helpers.py @@ -27,4 +27,4 @@ def create_api(hass: HomeAssistant, host: str, enable_ime: bool) -> AndroidTVRem def get_enable_ime(entry: AndroidTVRemoteConfigEntry) -> bool: """Get value of enable_ime option or its default value.""" - return entry.options.get(CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE) # type: ignore[no-any-return] + return bool(entry.options.get(CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE)) diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json index 822f514ca7c24f..29f5d623bbcbe9 100644 --- a/homeassistant/components/androidtv_remote/manifest.json +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_push", "loggers": ["androidtvremote2"], "quality_scale": "platinum", - "requirements": ["androidtvremote2==0.2.3"], + "requirements": ["androidtvremote2==0.3.1"], "zeroconf": ["_androidtvremote2._tcp.local."] } diff --git a/homeassistant/components/anglian_water/manifest.json b/homeassistant/components/anglian_water/manifest.json index b6f2dd3383871f..c81038e9731423 100644 --- a/homeassistant/components/anglian_water/manifest.json +++ b/homeassistant/components/anglian_water/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["pyanglianwater"], "quality_scale": "bronze", - "requirements": ["pyanglianwater==3.1.0"] + "requirements": ["pyanglianwater==3.1.1"] } diff --git a/homeassistant/components/anglian_water/sensor.py b/homeassistant/components/anglian_water/sensor.py index c12dd45212e166..52cd629f8bbbf1 100644 --- a/homeassistant/components/anglian_water/sensor.py +++ b/homeassistant/components/anglian_water/sensor.py @@ -4,6 +4,7 @@ from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime from enum import StrEnum from pyanglianwater.meter import SmartMeter @@ -32,13 +33,14 @@ class AnglianWaterSensor(StrEnum): YESTERDAY_WATER_COST = "yesterday_water_cost" YESTERDAY_SEWERAGE_COST = "yesterday_sewerage_cost" LATEST_READING = "latest_reading" + LAST_UPDATED = "last_updated" @dataclass(frozen=True, kw_only=True) class AnglianWaterSensorEntityDescription(SensorEntityDescription): """Describes AnglianWater sensor entity.""" - value_fn: Callable[[SmartMeter], float] + value_fn: Callable[[SmartMeter], float | datetime | None] ENTITY_DESCRIPTIONS: tuple[AnglianWaterSensorEntityDescription, ...] = ( @@ -76,6 +78,13 @@ class AnglianWaterSensorEntityDescription(SensorEntityDescription): translation_key=AnglianWaterSensor.YESTERDAY_SEWERAGE_COST, entity_category=EntityCategory.DIAGNOSTIC, ), + AnglianWaterSensorEntityDescription( + key=AnglianWaterSensor.LAST_UPDATED, + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda entity: entity.last_updated, + translation_key=AnglianWaterSensor.LAST_UPDATED, + entity_category=EntityCategory.DIAGNOSTIC, + ), ) @@ -112,6 +121,6 @@ def __init__( self.entity_description = description @property - def native_value(self) -> float | None: + def native_value(self) -> float | datetime | None: """Return the state of the sensor.""" return self.entity_description.value_fn(self.smart_meter) diff --git a/homeassistant/components/anglian_water/strings.json b/homeassistant/components/anglian_water/strings.json index 6db91b3b9b02eb..ae6895b98c888d 100644 --- a/homeassistant/components/anglian_water/strings.json +++ b/homeassistant/components/anglian_water/strings.json @@ -34,6 +34,9 @@ }, "entity": { "sensor": { + "last_updated": { + "name": "Last meter reading processed" + }, "latest_reading": { "name": "Latest reading" }, diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index 4f5643c30d17df..e479c1836ec3e3 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -19,7 +19,6 @@ from .const import ( CONF_CHAT_MODEL, - DATA_REPAIR_DEFER_RELOAD, DEFAULT_CONVERSATION_NAME, DEPRECATED_MODELS, DOMAIN, @@ -34,7 +33,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Anthropic.""" - hass.data.setdefault(DOMAIN, {}).setdefault(DATA_REPAIR_DEFER_RELOAD, set()) await async_migrate_integration(hass) return True @@ -85,11 +83,6 @@ async def async_update_options( hass: HomeAssistant, entry: AnthropicConfigEntry ) -> None: """Update options.""" - defer_reload_entries: set[str] = hass.data.setdefault(DOMAIN, {}).setdefault( - DATA_REPAIR_DEFER_RELOAD, set() - ) - if entry.entry_id in defer_reload_entries: - return await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/anthropic/ai_task.py b/homeassistant/components/anthropic/ai_task.py index 34b2500e430a15..8701e28577eefa 100644 --- a/homeassistant/components/anthropic/ai_task.py +++ b/homeassistant/components/anthropic/ai_task.py @@ -46,6 +46,7 @@ class AnthropicTaskEntity( ai_task.AITaskEntityFeature.GENERATE_DATA | ai_task.AITaskEntityFeature.SUPPORT_ATTACHMENTS ) + _attr_translation_key = "ai_task_data" async def _async_generate_data( self, diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index ddd75795cfa70e..36c4a80f85d47b 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -43,7 +43,9 @@ 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_RECOMMENDED, @@ -112,19 +114,12 @@ async def get_model_list(client: anthropic.AsyncAnthropic) -> list[SelectOptionD # Resolve alias from versioned model name: model_alias = ( model_info.id[:-9] - if model_info.id - not in ( - "claude-3-haiku-20240307", - "claude-3-5-haiku-20241022", - "claude-3-opus-20240229", - ) + 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" - if model_alias.endswith(("haiku", "opus", "sonnet")): - model_alias += "-latest" model_options.append( SelectOptionDict( label=model_info.display_name, @@ -422,6 +417,16 @@ async def async_step_model( else: self.options.pop(CONF_THINKING_EFFORT, None) + if not model.startswith(tuple(CODE_EXECUTION_UNSUPPORTED_MODELS)): + step_schema[ + 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( { diff --git a/homeassistant/components/anthropic/const.py b/homeassistant/components/anthropic/const.py index eb5b8acdfe1b6d..138f704aa0cce2 100644 --- a/homeassistant/components/anthropic/const.py +++ b/homeassistant/components/anthropic/const.py @@ -11,6 +11,7 @@ CONF_RECOMMENDED = "recommended" CONF_PROMPT = "prompt" CONF_CHAT_MODEL = "chat_model" +CONF_CODE_EXECUTION = "code_execution" CONF_MAX_TOKENS = "max_tokens" CONF_TEMPERATURE = "temperature" CONF_THINKING_BUDGET = "thinking_budget" @@ -23,10 +24,9 @@ CONF_WEB_SEARCH_COUNTRY = "country" CONF_WEB_SEARCH_TIMEZONE = "timezone" -DATA_REPAIR_DEFER_RELOAD = "repair_defer_reload" - DEFAULT = { CONF_CHAT_MODEL: "claude-haiku-4-5", + CONF_CODE_EXECUTION: False, CONF_MAX_TOKENS: 3000, CONF_TEMPERATURE: 1.0, CONF_THINKING_BUDGET: 0, @@ -39,8 +39,6 @@ MIN_THINKING_BUDGET = 1024 NON_THINKING_MODELS = [ - "claude-3-5", # Both sonnet and haiku - "claude-3-opus", "claude-3-haiku", ] @@ -53,7 +51,7 @@ "claude-opus-4-20250514", "claude-sonnet-4-0", "claude-sonnet-4-20250514", - "claude-3", + "claude-3-haiku", ] UNSUPPORTED_STRUCTURED_OUTPUT_MODELS = [ @@ -62,19 +60,17 @@ "claude-opus-4-20250514", "claude-sonnet-4-0", "claude-sonnet-4-20250514", - "claude-3", + "claude-3-haiku", ] WEB_SEARCH_UNSUPPORTED_MODELS = [ "claude-3-haiku", - "claude-3-opus", - "claude-3-5-sonnet-20240620", - "claude-3-5-sonnet-20241022", +] + +CODE_EXECUTION_UNSUPPORTED_MODELS = [ + "claude-3-haiku", ] DEPRECATED_MODELS = [ - "claude-3-5-haiku", - "claude-3-7-sonnet", - "claude-3-5-sonnet", - "claude-3-opus", + "claude-3", ] diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index 4eb40974b7ae18..ae6e28b6ef2816 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -37,6 +37,7 @@ class AnthropicConversationEntity( """Anthropic conversation agent.""" _attr_supports_streaming = True + _attr_translation_key = "conversation" def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the agent.""" diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py index f82cf5859cfc98..38a99cc39d9486 100644 --- a/homeassistant/components/anthropic/entity.py +++ b/homeassistant/components/anthropic/entity.py @@ -3,19 +3,23 @@ import base64 from collections.abc import AsyncGenerator, Callable, Iterable from dataclasses import dataclass, field +from datetime import UTC, datetime import json from mimetypes import guess_file_type from pathlib import Path -from typing import Any +from typing import Any, Literal, cast import anthropic from anthropic import AsyncStream from anthropic.types import ( Base64ImageSourceParam, Base64PDFSourceParam, + BashCodeExecutionToolResultBlock, CitationsDelta, CitationsWebSearchResultLocation, CitationWebSearchResultLocationParam, + CodeExecutionTool20250825Param, + Container, ContentBlockParam, DocumentBlockParam, ImageBlockParam, @@ -41,6 +45,7 @@ TextCitation, TextCitationParam, TextDelta, + TextEditorCodeExecutionToolResultBlock, ThinkingBlock, ThinkingBlockParam, ThinkingConfigAdaptiveParam, @@ -51,18 +56,21 @@ ToolChoiceAutoParam, ToolChoiceToolParam, ToolParam, - ToolResultBlockParam, ToolUnionParam, ToolUseBlock, ToolUseBlockParam, Usage, WebSearchTool20250305Param, - WebSearchToolRequestErrorParam, WebSearchToolResultBlock, - WebSearchToolResultBlockParam, - WebSearchToolResultError, + WebSearchToolResultBlockParamContentParam, +) +from anthropic.types.bash_code_execution_tool_result_block_param import ( + Content as BashCodeExecutionToolResultContentParam, ) from anthropic.types.message_create_params import MessageCreateParamsStreaming +from anthropic.types.text_editor_code_execution_tool_result_block_param import ( + Content as TextEditorCodeExecutionToolResultContentParam, +) import voluptuous as vol from voluptuous_openapi import convert @@ -74,10 +82,12 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.json import json_dumps from homeassistant.util import slugify +from homeassistant.util.json import JsonObjectType from . import AnthropicConfigEntry from .const import ( CONF_CHAT_MODEL, + CONF_CODE_EXECUTION, CONF_MAX_TOKENS, CONF_TEMPERATURE, CONF_THINKING_BUDGET, @@ -132,11 +142,23 @@ class ContentDetails: """Native data for AssistantContent.""" citation_details: list[CitationDetails] = field(default_factory=list) + thinking_signature: str | None = None + redacted_thinking: str | None = None + container: Container | None = None def has_content(self) -> bool: - """Check if there is any content.""" + """Check if there is any text content.""" return any(detail.length > 0 for detail in self.citation_details) + def __bool__(self) -> bool: + """Check if there is any thinking content or citations.""" + return ( + self.thinking_signature is not None + or self.redacted_thinking is not None + or self.container is not None + or self.has_citations() + ) + def has_citations(self) -> bool: """Check if there are any citations.""" return any(detail.citations for detail in self.citation_details) @@ -178,30 +200,53 @@ def delete_empty(self) -> None: def _convert_content( chat_content: Iterable[conversation.Content], -) -> list[MessageParam]: +) -> tuple[list[MessageParam], str | None]: """Transform HA chat_log content into Anthropic API format.""" messages: list[MessageParam] = [] + container_id: str | None = None for content in chat_content: if isinstance(content, conversation.ToolResultContent): + external_tool = True if content.tool_name == "web_search": - tool_result_block: ContentBlockParam = WebSearchToolResultBlockParam( - type="web_search_tool_result", - tool_use_id=content.tool_call_id, - content=content.tool_result["content"] - if "content" in content.tool_result - else WebSearchToolRequestErrorParam( - type="web_search_tool_result_error", - error_code=content.tool_result.get("error_code", "unavailable"), # type: ignore[typeddict-item] + tool_result_block: ContentBlockParam = { + "type": "web_search_tool_result", + "tool_use_id": content.tool_call_id, + "content": cast( + WebSearchToolResultBlockParamContentParam, + content.tool_result["content"] + if "content" in content.tool_result + else { + "type": "web_search_tool_result_error", + "error_code": content.tool_result.get( + "error_code", "unavailable" + ), + }, ), - ) - external_tool = True + } + 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 + ), + } + elif content.tool_name == "text_editor_code_execution": + tool_result_block = { + "type": "text_editor_code_execution_tool_result", + "tool_use_id": content.tool_call_id, + "content": cast( + TextEditorCodeExecutionToolResultContentParam, + content.tool_result, + ), + } else: - tool_result_block = ToolResultBlockParam( - type="tool_result", - tool_use_id=content.tool_call_id, - content=json_dumps(content.tool_result), - ) + tool_result_block = { + "type": "tool_result", + "tool_use_id": content.tool_call_id, + "content": json_dumps(content.tool_result), + } external_tool = False if not messages or messages[-1]["role"] != ( "assistant" if external_tool else "user" @@ -246,29 +291,33 @@ def _convert_content( content=[], ) ) + elif isinstance(messages[-1]["content"], str): + messages[-1]["content"] = [ + TextBlockParam(type="text", text=messages[-1]["content"]), + ] - if isinstance(content.native, ThinkingBlock): - messages[-1]["content"].append( # type: ignore[union-attr] - ThinkingBlockParam( - type="thinking", - thinking=content.thinking_content or "", - signature=content.native.signature, + if isinstance(content.native, ContentDetails): + if content.native.thinking_signature: + messages[-1]["content"].append( # type: ignore[union-attr] + ThinkingBlockParam( + type="thinking", + thinking=content.thinking_content or "", + signature=content.native.thinking_signature, + ) ) - ) - elif isinstance(content.native, RedactedThinkingBlock): - redacted_thinking_block = RedactedThinkingBlockParam( - type="redacted_thinking", - data=content.native.data, - ) - if isinstance(messages[-1]["content"], str): - messages[-1]["content"] = [ - TextBlockParam(type="text", text=messages[-1]["content"]), - redacted_thinking_block, - ] - else: - messages[-1]["content"].append( # type: ignore[attr-defined] - redacted_thinking_block + if content.native.redacted_thinking: + messages[-1]["content"].append( # type: ignore[union-attr] + RedactedThinkingBlockParam( + type="redacted_thinking", + data=content.native.redacted_thinking, + ) ) + if ( + content.native.container is not None + and content.native.container.expires_at > datetime.now(UTC) + ): + container_id = content.native.container.id + if content.content: current_index = 0 for detail in ( @@ -309,16 +358,30 @@ def _convert_content( text=content.content[current_index:], ) ) + if content.tool_calls: messages[-1]["content"].extend( # type: ignore[union-attr] [ ServerToolUseBlockParam( type="server_tool_use", id=tool_call.id, - name="web_search", + name=cast( + Literal[ + "web_search", + "bash_code_execution", + "text_editor_code_execution", + ], + tool_call.tool_name, + ), input=tool_call.tool_args, ) - if tool_call.external and tool_call.tool_name == "web_search" + if tool_call.external + and tool_call.tool_name + in [ + "web_search", + "bash_code_execution", + "text_editor_code_execution", + ] else ToolUseBlockParam( type="tool_use", id=tool_call.id, @@ -328,11 +391,19 @@ def _convert_content( for tool_call in content.tool_calls ] ) + + if ( + isinstance(messages[-1]["content"], list) + and len(messages[-1]["content"]) == 1 + and messages[-1]["content"][0]["type"] == "text" + ): + # If there is only one text block, simplify the content to a string + messages[-1]["content"] = messages[-1]["content"][0]["text"] else: - # Note: We don't pass SystemContent here as its passed to the API as the prompt - raise TypeError(f"Unexpected content type: {type(content)}") + # 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") - return messages + return messages, container_id async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place @@ -371,23 +442,20 @@ 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: - raise TypeError("Expected a stream of messages") + 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 - has_native = False - first_block: bool + first_block: bool = True async for response in stream: LOGGER.debug("Received response: %s", response) if isinstance(response, RawMessageStartEvent): - if response.message.role != "assistant": - raise ValueError("Unexpected message role") input_usage = response.message.usage first_block = True elif isinstance(response, RawContentBlockStartEvent): @@ -401,13 +469,12 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have current_tool_args = "" if response.content_block.name == output_tool: if first_block or content_details.has_content(): - if content_details.has_citations(): + if content_details: content_details.delete_empty() yield {"native": content_details} content_details = ContentDetails() content_details.add_citation_detail() yield {"role": "assistant"} - has_native = False 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. @@ -418,12 +485,11 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have and content_details.has_content() ) ): - if content_details.has_citations(): + if content_details: content_details.delete_empty() yield {"native": content_details} content_details = ContentDetails() yield {"role": "assistant"} - has_native = False first_block = False content_details.add_citation_detail() if response.content_block.text: @@ -432,14 +498,13 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have ) yield {"content": response.content_block.text} elif isinstance(response.content_block, ThinkingBlock): - if first_block or has_native: - if content_details.has_citations(): + 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"} - has_native = False first_block = False elif isinstance(response.content_block, RedactedThinkingBlock): LOGGER.debug( @@ -447,17 +512,15 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have "encrypted for safety reasons. This doesn’t affect the quality of " "responses" ) - if has_native: - if content_details.has_citations(): + 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"} - has_native = False first_block = False - yield {"native": response.content_block} - has_native = True + content_details.redacted_thinking = response.content_block.data elif isinstance(response.content_block, ServerToolUseBlock): current_tool_block = ServerToolUseBlockParam( type="server_tool_use", @@ -466,8 +529,15 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have input={}, ) current_tool_args = "" - elif isinstance(response.content_block, WebSearchToolResultBlock): - if content_details.has_citations(): + elif isinstance( + response.content_block, + ( + WebSearchToolResultBlock, + BashCodeExecutionToolResultBlock, + TextEditorCodeExecutionToolResultBlock, + ), + ): + if content_details: content_details.delete_empty() yield {"native": content_details} content_details = ContentDetails() @@ -475,26 +545,16 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have yield { "role": "tool_result", "tool_call_id": response.content_block.tool_use_id, - "tool_name": "web_search", + "tool_name": response.content_block.type.removesuffix( + "_tool_result" + ), "tool_result": { - "type": "web_search_tool_result_error", - "error_code": response.content_block.content.error_code, + "content": cast( + JsonObjectType, response.content_block.to_dict()["content"] + ) } - if isinstance( - response.content_block.content, WebSearchToolResultError - ) - else { - "content": [ - { - "type": "web_search_result", - "encrypted_content": block.encrypted_content, - "page_age": block.page_age, - "title": block.title, - "url": block.url, - } - for block in response.content_block.content - ] - }, + if isinstance(response.content_block.content, list) + else cast(JsonObjectType, response.content_block.content.to_dict()), } first_block = True elif isinstance(response, RawContentBlockDeltaEvent): @@ -510,19 +570,16 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have else: current_tool_args += response.delta.partial_json elif isinstance(response.delta, TextDelta): - content_details.citation_details[-1].length += len(response.delta.text) - yield {"content": response.delta.text} + if response.delta.text: + content_details.citation_details[-1].length += len( + response.delta.text + ) + yield {"content": response.delta.text} elif isinstance(response.delta, ThinkingDelta): - yield {"thinking_content": response.delta.thinking} + if response.delta.thinking: + yield {"thinking_content": response.delta.thinking} elif isinstance(response.delta, SignatureDelta): - yield { - "native": ThinkingBlock( - type="thinking", - thinking="", - signature=response.delta.signature, - ) - } - has_native = True + content_details.thinking_signature = response.delta.signature elif isinstance(response.delta, CitationsDelta): content_details.add_citation(response.delta.citation) elif isinstance(response, RawContentBlockStopEvent): @@ -546,10 +603,11 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have 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.has_citations(): + if content_details: content_details.delete_empty() yield {"native": content_details} content_details = ContentDetails() @@ -606,7 +664,7 @@ async def _async_handle_chat_log( system = chat_log.content[0] if not isinstance(system, conversation.SystemContent): - raise TypeError("First message must be a system message") + raise HomeAssistantError("First message must be a system message") # System prompt with caching enabled system_prompt: list[TextBlockParam] = [ @@ -617,7 +675,7 @@ async def _async_handle_chat_log( ) ] - messages = _convert_content(chat_log.content[1:]) + messages, container_id = _convert_content(chat_log.content[1:]) model = options.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL]) @@ -627,6 +685,7 @@ async def _async_handle_chat_log( max_tokens=options.get(CONF_MAX_TOKENS, DEFAULT[CONF_MAX_TOKENS]), system=system_prompt, stream=True, + container=container_id, ) if not model.startswith(tuple(NON_ADAPTIVE_THINKING_MODELS)): @@ -665,6 +724,14 @@ 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.get(CONF_WEB_SEARCH): web_search = WebSearchTool20250305Param( name="web_search", @@ -775,21 +842,25 @@ async def _async_handle_chat_log( try: stream = await client.messages.create(**model_args) - messages.extend( - _convert_content( - [ - content - async for content in chat_log.async_add_delta_content_stream( - self.entity_id, - _transform_stream( - chat_log, - stream, - output_tool=structure_name or None, - ), - ) - ] - ) + new_messages, model_args["container"] = _convert_content( + [ + content + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, + _transform_stream( + chat_log, + stream, + output_tool=structure_name or None, + ), + ) + ] ) + messages.extend(new_messages) + except anthropic.AuthenticationError as err: + self.entry.async_start_reauth(self.hass) + raise HomeAssistantError( + "Authentication error with Anthropic API, reauthentication required" + ) from err except anthropic.AnthropicError as err: raise HomeAssistantError( f"Sorry, I had a problem talking to Anthropic: {err}" diff --git a/homeassistant/components/anthropic/icons.json b/homeassistant/components/anthropic/icons.json new file mode 100644 index 00000000000000..4af128167dc3fe --- /dev/null +++ b/homeassistant/components/anthropic/icons.json @@ -0,0 +1,14 @@ +{ + "entity": { + "ai_task": { + "ai_task_data": { + "default": "mdi:asterisk" + } + }, + "conversation": { + "conversation": { + "default": "mdi:asterisk" + } + } + } +} diff --git a/homeassistant/components/anthropic/manifest.json b/homeassistant/components/anthropic/manifest.json index 3f60c7b62273c3..7ed34c517d1248 100644 --- a/homeassistant/components/anthropic/manifest.json +++ b/homeassistant/components/anthropic/manifest.json @@ -1,6 +1,6 @@ { "domain": "anthropic", - "name": "Anthropic Conversation", + "name": "Anthropic", "after_dependencies": ["assist_pipeline", "intent"], "codeowners": ["@Shulyaka"], "config_flow": true, @@ -8,5 +8,6 @@ "documentation": "https://www.home-assistant.io/integrations/anthropic", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["anthropic==0.78.0"] + "quality_scale": "bronze", + "requirements": ["anthropic==0.83.0"] } diff --git a/homeassistant/components/anthropic/quality_scale.yaml b/homeassistant/components/anthropic/quality_scale.yaml new file mode 100644 index 00000000000000..37f605b1532a88 --- /dev/null +++ b/homeassistant/components/anthropic/quality_scale.yaml @@ -0,0 +1,105 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + Integration has no actions. + appropriate-polling: + status: exempt + comment: | + Integration does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + Integration has no actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Integration does not subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: + status: exempt + comment: | + The API does not limit parallel updates. + reauthentication-flow: done + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + Service integration, no discovery. + discovery: + status: exempt + comment: | + Service integration, no discovery. + docs-data-update: + status: exempt + comment: | + No data updates. + docs-examples: + status: todo + comment: | + To give examples of how people use the integration + docs-known-limitations: done + docs-supported-devices: + status: todo + comment: | + To write something about what models we support. + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: + status: exempt + comment: | + Service integration, no devices. + entity-category: + status: exempt + comment: | + No entities with categories. + entity-device-class: + status: exempt + comment: | + No entities with device classes. + entity-disabled-by-default: + status: exempt + comment: | + No entities disabled by default. + entity-translations: todo + exception-translations: todo + icon-translations: done + reconfiguration-flow: done + repair-issues: done + stale-devices: + status: exempt + comment: | + Service integration, no devices. + # Platinum + async-dependency: done + inject-websession: + status: done + comment: | + Uses `httpx` session. + strict-typing: done diff --git a/homeassistant/components/anthropic/repairs.py b/homeassistant/components/anthropic/repairs.py index 8f35fc548da3c9..4594967d379570 100644 --- a/homeassistant/components/anthropic/repairs.py +++ b/homeassistant/components/anthropic/repairs.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Iterator -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING import voluptuous as vol @@ -12,16 +12,14 @@ from homeassistant.config_entries import ConfigEntryState, ConfigSubentry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, +) from .config_flow import get_model_list -from .const import ( - CONF_CHAT_MODEL, - DATA_REPAIR_DEFER_RELOAD, - DEFAULT, - DEPRECATED_MODELS, - DOMAIN, -) +from .const import CONF_CHAT_MODEL, DEPRECATED_MODELS, DOMAIN if TYPE_CHECKING: from . import AnthropicConfigEntry @@ -33,8 +31,7 @@ class ModelDeprecatedRepairFlow(RepairsFlow): _subentry_iter: Iterator[tuple[str, str]] | None _current_entry_id: str | None _current_subentry_id: str | None - _reload_pending: set[str] - _pending_updates: dict[str, dict[str, str]] + _model_list_cache: dict[str, list[SelectOptionDict]] | None def __init__(self) -> None: """Initialize the flow.""" @@ -42,42 +39,51 @@ def __init__(self) -> None: self._subentry_iter = None self._current_entry_id = None self._current_subentry_id = None - self._reload_pending = set() - self._pending_updates = {} + self._model_list_cache = None async def async_step_init( - self, user_input: dict[str, str] | None = None + self, user_input: dict[str, str] ) -> data_entry_flow.FlowResult: - """Handle the first step of a fix flow.""" - previous_entry_id: str | None = None - if user_input is not None: - previous_entry_id = self._async_update_current_subentry(user_input) - self._clear_current_target() + """Handle the steps of a fix flow.""" + if user_input.get(CONF_CHAT_MODEL): + self._async_update_current_subentry(user_input) target = await self._async_next_target() - next_entry_id = target[0].entry_id if target else None - if previous_entry_id and previous_entry_id != next_entry_id: - await self._async_apply_pending_updates(previous_entry_id) if target is None: - await self._async_apply_all_pending_updates() return self.async_create_entry(data={}) entry, subentry, model = target - client = entry.runtime_data - model_list = [ - model_option - for model_option in await get_model_list(client) - if not model_option["value"].startswith(tuple(DEPRECATED_MODELS)) - ] + if self._model_list_cache is None: + self._model_list_cache = {} + if entry.entry_id in self._model_list_cache: + model_list = self._model_list_cache[entry.entry_id] + else: + client = entry.runtime_data + model_list = [ + model_option + for model_option in await get_model_list(client) + if not model_option["value"].startswith(tuple(DEPRECATED_MODELS)) + ] + self._model_list_cache[entry.entry_id] = model_list if "opus" in model: - suggested_model = "claude-opus-4-5" - elif "haiku" in model: - suggested_model = "claude-haiku-4-5" + family = "claude-opus" elif "sonnet" in model: - suggested_model = "claude-sonnet-4-5" + family = "claude-sonnet" else: - suggested_model = cast(str, DEFAULT[CONF_CHAT_MODEL]) + family = "claude-haiku" + + suggested_model = next( + ( + model_option["value"] + for model_option in sorted( + (m for m in model_list if family in m["value"]), + key=lambda x: x["value"], + reverse=True, + ) + ), + vol.UNDEFINED, + ) schema = vol.Schema( { @@ -124,6 +130,8 @@ async def _async_next_target( except StopIteration: return None + # Verify that the entry/subentry still exists and the model is still + # deprecated. This may have changed since we started the repair flow. entry = self.hass.config_entries.async_get_entry(entry_id) if entry is None: continue @@ -132,9 +140,7 @@ async def _async_next_target( if subentry is None: continue - model = self._pending_model(entry_id, subentry_id) - if model is None: - model = subentry.data.get(CONF_CHAT_MODEL) + model = subentry.data.get(CONF_CHAT_MODEL) if not model or not model.startswith(tuple(DEPRECATED_MODELS)): continue @@ -142,36 +148,30 @@ async def _async_next_target( self._current_subentry_id = subentry_id return entry, subentry, model - def _async_update_current_subentry(self, user_input: dict[str, str]) -> str | None: + def _async_update_current_subentry(self, user_input: dict[str, str]) -> None: """Update the currently selected subentry.""" - if not self._current_entry_id or not self._current_subentry_id: - return None - - entry = self.hass.config_entries.async_get_entry(self._current_entry_id) - if entry is None: - return None - - subentry = entry.subentries.get(self._current_subentry_id) - if subentry is None: - return None + if ( + self._current_entry_id is None + or self._current_subentry_id is None + or ( + entry := self.hass.config_entries.async_get_entry( + self._current_entry_id + ) + ) + is None + or (subentry := entry.subentries.get(self._current_subentry_id)) is None + ): + raise HomeAssistantError("Subentry not found") updated_data = { **subentry.data, CONF_CHAT_MODEL: user_input[CONF_CHAT_MODEL], } - if updated_data == subentry.data: - return entry.entry_id - self._queue_pending_update( - entry.entry_id, - subentry.subentry_id, - updated_data[CONF_CHAT_MODEL], + self.hass.config_entries.async_update_subentry( + entry, + subentry, + data=updated_data, ) - return entry.entry_id - - def _clear_current_target(self) -> None: - """Clear current target tracking.""" - self._current_entry_id = None - self._current_subentry_id = None def _format_subentry_type(self, subentry_type: str) -> str: """Return a user-friendly subentry type label.""" @@ -181,91 +181,6 @@ def _format_subentry_type(self, subentry_type: str) -> str: return "AI task" return subentry_type - def _queue_pending_update( - self, entry_id: str, subentry_id: str, model: str - ) -> None: - """Store a pending model update for a subentry.""" - self._pending_updates.setdefault(entry_id, {})[subentry_id] = model - - def _pending_model(self, entry_id: str, subentry_id: str) -> str | None: - """Return a pending model update if one exists.""" - return self._pending_updates.get(entry_id, {}).get(subentry_id) - - def _mark_entry_for_reload(self, entry_id: str) -> None: - """Prevent reload until repairs are complete for the entry.""" - self._reload_pending.add(entry_id) - defer_reload_entries: set[str] = self.hass.data.setdefault( - DOMAIN, {} - ).setdefault(DATA_REPAIR_DEFER_RELOAD, set()) - defer_reload_entries.add(entry_id) - - async def _async_reload_entry(self, entry_id: str) -> None: - """Reload an entry once all repairs are completed.""" - if entry_id not in self._reload_pending: - return - - entry = self.hass.config_entries.async_get_entry(entry_id) - if entry is not None and entry.state is not ConfigEntryState.LOADED: - self._clear_defer_reload(entry_id) - self._reload_pending.discard(entry_id) - return - - if entry is not None: - await self.hass.config_entries.async_reload(entry_id) - - self._clear_defer_reload(entry_id) - self._reload_pending.discard(entry_id) - - def _clear_defer_reload(self, entry_id: str) -> None: - """Remove entry from the deferred reload set.""" - defer_reload_entries: set[str] = self.hass.data.setdefault( - DOMAIN, {} - ).setdefault(DATA_REPAIR_DEFER_RELOAD, set()) - defer_reload_entries.discard(entry_id) - - async def _async_apply_pending_updates(self, entry_id: str) -> None: - """Apply pending subentry updates for a single entry.""" - updates = self._pending_updates.pop(entry_id, None) - if not updates: - return - - entry = self.hass.config_entries.async_get_entry(entry_id) - if entry is None or entry.state is not ConfigEntryState.LOADED: - return - - changed = False - for subentry_id, model in updates.items(): - subentry = entry.subentries.get(subentry_id) - if subentry is None: - continue - - updated_data = { - **subentry.data, - CONF_CHAT_MODEL: model, - } - if updated_data == subentry.data: - continue - - if not changed: - self._mark_entry_for_reload(entry_id) - changed = True - - self.hass.config_entries.async_update_subentry( - entry, - subentry, - data=updated_data, - ) - - if not changed: - return - - await self._async_reload_entry(entry_id) - - async def _async_apply_all_pending_updates(self) -> None: - """Apply all pending updates across entries.""" - for entry_id in list(self._pending_updates): - await self._async_apply_pending_updates(entry_id) - async def async_create_fix_flow( hass: HomeAssistant, diff --git a/homeassistant/components/anthropic/strings.json b/homeassistant/components/anthropic/strings.json index 21c67d5d6fb5db..4e34085a09c7fc 100644 --- a/homeassistant/components/anthropic/strings.json +++ b/homeassistant/components/anthropic/strings.json @@ -69,6 +69,7 @@ }, "model": { "data": { + "code_execution": "[%key:component::anthropic::config_subentries::conversation::step::model::data::code_execution%]", "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%]", "user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data::user_location%]", @@ -76,6 +77,7 @@ "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%]", "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%]", "user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::user_location%]", @@ -127,6 +129,7 @@ }, "model": { "data": { + "code_execution": "Code execution", "thinking_budget": "Thinking budget", "thinking_effort": "Thinking effort", "user_location": "Include home location", @@ -134,6 +137,7 @@ "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.", "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", "user_location": "Localize search results based on home location", diff --git a/homeassistant/components/aosmith/water_heater.py b/homeassistant/components/aosmith/water_heater.py index d29b00955b6e14..3f88fdd497dae7 100644 --- a/homeassistant/components/aosmith/water_heater.py +++ b/homeassistant/components/aosmith/water_heater.py @@ -120,7 +120,7 @@ def current_operation(self) -> str: return MODE_AOSMITH_TO_HA.get(self.device.status.current_mode, STATE_OFF) @property - def is_away_mode_on(self): + def is_away_mode_on(self) -> bool: """Return True if away mode is on.""" return self.device.status.current_mode == AOSmithOperationMode.VACATION diff --git a/homeassistant/components/aquostv/media_player.py b/homeassistant/components/aquostv/media_player.py index 734bd10cfbe0d5..3fc6bed54a1c37 100644 --- a/homeassistant/components/aquostv/media_player.py +++ b/homeassistant/components/aquostv/media_player.py @@ -117,6 +117,7 @@ class SharpAquosTVDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.PLAY ) + _attr_volume_step = 2 / 60 def __init__( self, name: str, remote: sharp_aquos_rc.TV, power_on_enabled: bool = False @@ -161,22 +162,6 @@ def turn_off(self) -> None: """Turn off tvplayer.""" self._remote.power(0) - @_retry - def volume_up(self) -> None: - """Volume up the media player.""" - if self.volume_level is None: - _LOGGER.debug("Unknown volume in volume_up") - return - self._remote.volume(int(self.volume_level * 60) + 2) - - @_retry - def volume_down(self) -> None: - """Volume down media player.""" - if self.volume_level is None: - _LOGGER.debug("Unknown volume in volume_down") - return - self._remote.volume(int(self.volume_level * 60) - 2) - @_retry def set_volume_level(self, volume: float) -> None: """Set Volume media player.""" diff --git a/homeassistant/components/arcam_fmj/__init__.py b/homeassistant/components/arcam_fmj/__init__.py index 71639ed83888ac..df088738a649a6 100644 --- a/homeassistant/components/arcam_fmj/__init__.py +++ b/homeassistant/components/arcam_fmj/__init__.py @@ -8,46 +8,55 @@ from arcam.fmj import ConnectionFailed from arcam.fmj.client import Client -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import ( - DEFAULT_SCAN_INTERVAL, - SIGNAL_CLIENT_DATA, - SIGNAL_CLIENT_STARTED, - SIGNAL_CLIENT_STOPPED, -) - -type ArcamFmjConfigEntry = ConfigEntry[Client] +from .const import DEFAULT_SCAN_INTERVAL +from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator, ArcamFmjRuntimeData _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.MEDIA_PLAYER] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.MEDIA_PLAYER, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ArcamFmjConfigEntry) -> bool: """Set up config entry.""" - entry.runtime_data = Client(entry.data[CONF_HOST], entry.data[CONF_PORT]) + client = Client(entry.data[CONF_HOST], entry.data[CONF_PORT]) + + coordinators: dict[int, ArcamFmjCoordinator] = {} + for zone in (1, 2): + coordinator = ArcamFmjCoordinator(hass, entry, client, zone) + coordinators[zone] = coordinator + + entry.runtime_data = ArcamFmjRuntimeData(client, coordinators) entry.async_create_background_task( - hass, _run_client(hass, entry.runtime_data, DEFAULT_SCAN_INTERVAL), "arcam_fmj" + hass, + _run_client(hass, entry.runtime_data, DEFAULT_SCAN_INTERVAL), + "arcam_fmj", ) 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: ArcamFmjConfigEntry) -> bool: """Cleanup before removing config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def _run_client(hass: HomeAssistant, client: Client, interval: float) -> None: +async def _run_client( + hass: HomeAssistant, + runtime_data: ArcamFmjRuntimeData, + interval: float, +) -> None: + client = runtime_data.client + coordinators = runtime_data.coordinators + def _listen(_: Any) -> None: - async_dispatcher_send(hass, SIGNAL_CLIENT_DATA, client.host) + for coordinator in coordinators.values(): + coordinator.async_notify_data_updated() while True: try: @@ -55,16 +64,21 @@ def _listen(_: Any) -> None: await client.start() _LOGGER.debug("Client connected %s", client.host) - async_dispatcher_send(hass, SIGNAL_CLIENT_STARTED, client.host) try: + for coordinator in coordinators.values(): + await coordinator.state.start() + with client.listen(_listen): + for coordinator in coordinators.values(): + coordinator.async_notify_connected() await client.process() finally: await client.stop() _LOGGER.debug("Client disconnected %s", client.host) - async_dispatcher_send(hass, SIGNAL_CLIENT_STOPPED, client.host) + for coordinator in coordinators.values(): + coordinator.async_notify_disconnected() except ConnectionFailed: await asyncio.sleep(interval) diff --git a/homeassistant/components/arcam_fmj/binary_sensor.py b/homeassistant/components/arcam_fmj/binary_sensor.py new file mode 100644 index 00000000000000..0addfdb4aa2aee --- /dev/null +++ b/homeassistant/components/arcam_fmj/binary_sensor.py @@ -0,0 +1,68 @@ +"""Arcam binary sensors for incoming stream info.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from arcam.fmj.state import State + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import ArcamFmjConfigEntry +from .entity import ArcamFmjEntity + + +@dataclass(frozen=True, kw_only=True) +class ArcamFmjBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes an Arcam FMJ binary sensor entity.""" + + value_fn: Callable[[State], bool | None] + + +BINARY_SENSORS: tuple[ArcamFmjBinarySensorEntityDescription, ...] = ( + ArcamFmjBinarySensorEntityDescription( + key="incoming_video_interlaced", + translation_key="incoming_video_interlaced", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda state: ( + vp.interlaced + if (vp := state.get_incoming_video_parameters()) is not None + else None + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ArcamFmjConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Arcam FMJ binary sensors from a config entry.""" + coordinators = config_entry.runtime_data.coordinators + + entities: list[ArcamFmjBinarySensorEntity] = [] + for coordinator in coordinators.values(): + entities.extend( + ArcamFmjBinarySensorEntity(coordinator, description) + for description in BINARY_SENSORS + ) + async_add_entities(entities) + + +class ArcamFmjBinarySensorEntity(ArcamFmjEntity, BinarySensorEntity): + """Representation of an Arcam FMJ binary sensor.""" + + entity_description: ArcamFmjBinarySensorEntityDescription + + @property + def is_on(self) -> bool | None: + """Return the binary sensor value.""" + return self.entity_description.value_fn(self.coordinator.state) diff --git a/homeassistant/components/arcam_fmj/const.py b/homeassistant/components/arcam_fmj/const.py index 7f62c78d56b8e5..19d1dd3d73309b 100644 --- a/homeassistant/components/arcam_fmj/const.py +++ b/homeassistant/components/arcam_fmj/const.py @@ -2,10 +2,6 @@ DOMAIN = "arcam_fmj" -SIGNAL_CLIENT_STARTED = "arcam.client_started" -SIGNAL_CLIENT_STOPPED = "arcam.client_stopped" -SIGNAL_CLIENT_DATA = "arcam.client_data" - EVENT_TURN_ON = "arcam_fmj.turn_on" DEFAULT_PORT = 50000 diff --git a/homeassistant/components/arcam_fmj/coordinator.py b/homeassistant/components/arcam_fmj/coordinator.py new file mode 100644 index 00000000000000..83faef37d10f4c --- /dev/null +++ b/homeassistant/components/arcam_fmj/coordinator.py @@ -0,0 +1,97 @@ +"""Coordinator for Arcam FMJ integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging + +from arcam.fmj import ConnectionFailed +from arcam.fmj.client import Client +from arcam.fmj.state import State + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class ArcamFmjRuntimeData: + """Runtime data for Arcam FMJ integration.""" + + client: Client + coordinators: dict[int, ArcamFmjCoordinator] + + +type ArcamFmjConfigEntry = ConfigEntry[ArcamFmjRuntimeData] + + +class ArcamFmjCoordinator(DataUpdateCoordinator[None]): + """Coordinator for a single Arcam FMJ zone.""" + + config_entry: ArcamFmjConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: ArcamFmjConfigEntry, + client: Client, + zone: int, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=f"Arcam FMJ zone {zone}", + ) + self.client = client + self.state = State(client, zone) + self.last_update_success = False + + name = config_entry.title + unique_id = config_entry.unique_id or config_entry.entry_id + unique_id_device = unique_id + if zone != 1: + unique_id_device += f"-{zone}" + name += f" Zone {zone}" + + self.device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id_device)}, + manufacturer="Arcam", + model="Arcam FMJ AVR", + name=name, + ) + self.zone_unique_id = f"{unique_id}-{zone}" + + if zone != 1: + self.device_info["via_device"] = (DOMAIN, unique_id) + + async def _async_update_data(self) -> None: + """Fetch data for manual refresh.""" + try: + await self.state.update() + except ConnectionFailed as err: + raise UpdateFailed( + f"Connection failed during update for zone {self.state.zn}" + ) from err + + @callback + def async_notify_data_updated(self) -> None: + """Notify that new data has been received from the device.""" + self.async_set_updated_data(None) + + @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() diff --git a/homeassistant/components/arcam_fmj/entity.py b/homeassistant/components/arcam_fmj/entity.py new file mode 100644 index 00000000000000..6d635a5f1c5048 --- /dev/null +++ b/homeassistant/components/arcam_fmj/entity.py @@ -0,0 +1,28 @@ +"""Base entity for Arcam FMJ integration.""" + +from __future__ import annotations + +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import ArcamFmjCoordinator + + +class ArcamFmjEntity(CoordinatorEntity[ArcamFmjCoordinator]): + """Base entity for Arcam FMJ.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: ArcamFmjCoordinator, + description: EntityDescription | None = None, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._attr_device_info = coordinator.device_info + self._attr_entity_registry_enabled_default = coordinator.state.zn == 1 + self._attr_unique_id = coordinator.zone_unique_id + if description is not None: + self._attr_unique_id = f"{self._attr_unique_id}-{description.key}" + self.entity_description = description diff --git a/homeassistant/components/arcam_fmj/icons.json b/homeassistant/components/arcam_fmj/icons.json new file mode 100644 index 00000000000000..3ce561661d0234 --- /dev/null +++ b/homeassistant/components/arcam_fmj/icons.json @@ -0,0 +1,35 @@ +{ + "entity": { + "binary_sensor": { + "incoming_video_interlaced": { + "default": "mdi:reorder-horizontal" + } + }, + "sensor": { + "incoming_audio_config": { + "default": "mdi:surround-sound" + }, + "incoming_audio_format": { + "default": "mdi:dolby" + }, + "incoming_audio_sample_rate": { + "default": "mdi:waveform" + }, + "incoming_video_aspect_ratio": { + "default": "mdi:aspect-ratio" + }, + "incoming_video_colorspace": { + "default": "mdi:palette" + }, + "incoming_video_horizontal_resolution": { + "default": "mdi:arrow-expand-horizontal" + }, + "incoming_video_refresh_rate": { + "default": "mdi:animation" + }, + "incoming_video_vertical_resolution": { + "default": "mdi:arrow-expand-vertical" + } + } + } +} diff --git a/homeassistant/components/arcam_fmj/media_player.py b/homeassistant/components/arcam_fmj/media_player.py index cd4ed7bbb0563b..04451c692ce01e 100644 --- a/homeassistant/components/arcam_fmj/media_player.py +++ b/homeassistant/components/arcam_fmj/media_player.py @@ -8,7 +8,6 @@ from typing import Any from arcam.fmj import ConnectionFailed, SourceCodes -from arcam.fmj.state import State from homeassistant.components.media_player import ( BrowseError, @@ -20,20 +19,13 @@ MediaType, ) from homeassistant.const import ATTR_ENTITY_ID -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import ArcamFmjConfigEntry -from .const import ( - DOMAIN, - EVENT_TURN_ON, - SIGNAL_CLIENT_DATA, - SIGNAL_CLIENT_STARTED, - SIGNAL_CLIENT_STOPPED, -) +from .const import EVENT_TURN_ON +from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator +from .entity import ArcamFmjEntity _LOGGER = logging.getLogger(__name__) @@ -44,19 +36,10 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the configuration entry.""" - - client = config_entry.runtime_data + coordinators = config_entry.runtime_data.coordinators async_add_entities( - [ - ArcamFmj( - config_entry.title, - State(client, zone), - config_entry.unique_id or config_entry.entry_id, - ) - for zone in (1, 2) - ], - True, + [ArcamFmj(coordinators[zone]) for zone in (1, 2)], ) @@ -77,21 +60,13 @@ async def _convert_exception(*args: _P.args, **kwargs: _P.kwargs) -> _R: return _convert_exception -class ArcamFmj(MediaPlayerEntity): +class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity): """Representation of a media device.""" - _attr_should_poll = False - _attr_has_entity_name = True - - def __init__( - self, - device_name: str, - state: State, - uuid: str, - ) -> None: + def __init__(self, coordinator: ArcamFmjCoordinator) -> None: """Initialize device.""" - self._state = state - self._attr_name = f"Zone {state.zn}" + super().__init__(coordinator) + self._state = coordinator.state self._attr_supported_features = ( MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.PLAY_MEDIA @@ -102,18 +77,8 @@ def __init__( | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.TURN_ON ) - if state.zn == 1: + if self._state.zn == 1: self._attr_supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE - self._attr_unique_id = f"{uuid}-{state.zn}" - self._attr_entity_registry_enabled_default = state.zn == 1 - self._attr_device_info = DeviceInfo( - identifiers={ - (DOMAIN, uuid), - }, - manufacturer="Arcam", - model="Arcam FMJ AVR", - name=device_name, - ) @property def state(self) -> MediaPlayerState: @@ -122,49 +87,6 @@ def state(self) -> MediaPlayerState: return MediaPlayerState.ON return MediaPlayerState.OFF - async def async_added_to_hass(self) -> None: - """Once registered, add listener for events.""" - await self._state.start() - try: - await self._state.update() - except ConnectionFailed as connection: - _LOGGER.debug("Connection lost during addition: %s", connection) - - @callback - def _data(host: str) -> None: - if host == self._state.client.host: - self.async_write_ha_state() - - @callback - def _started(host: str) -> None: - if host == self._state.client.host: - self.async_schedule_update_ha_state(force_refresh=True) - - @callback - def _stopped(host: str) -> None: - if host == self._state.client.host: - self.async_schedule_update_ha_state(force_refresh=True) - - self.async_on_remove( - async_dispatcher_connect(self.hass, SIGNAL_CLIENT_DATA, _data) - ) - - self.async_on_remove( - async_dispatcher_connect(self.hass, SIGNAL_CLIENT_STARTED, _started) - ) - - self.async_on_remove( - async_dispatcher_connect(self.hass, SIGNAL_CLIENT_STOPPED, _stopped) - ) - - async def async_update(self) -> None: - """Force update of state.""" - _LOGGER.debug("Update state %s", self.name) - try: - await self._state.update() - except ConnectionFailed as connection: - _LOGGER.debug("Connection lost during update: %s", connection) - @convert_exception async def async_mute_volume(self, mute: bool) -> None: """Send mute command.""" diff --git a/homeassistant/components/arcam_fmj/sensor.py b/homeassistant/components/arcam_fmj/sensor.py new file mode 100644 index 00000000000000..f57ab2649dc701 --- /dev/null +++ b/homeassistant/components/arcam_fmj/sensor.py @@ -0,0 +1,162 @@ +"""Arcam sensors for incoming stream info.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from arcam.fmj import IncomingVideoAspectRatio, IncomingVideoColorspace +from arcam.fmj.state import IncomingAudioConfig, IncomingAudioFormat, State + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import EntityCategory, UnitOfFrequency +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import ArcamFmjConfigEntry +from .entity import ArcamFmjEntity + + +@dataclass(frozen=True, kw_only=True) +class ArcamFmjSensorEntityDescription(SensorEntityDescription): + """Describes an Arcam FMJ sensor entity.""" + + value_fn: Callable[[State], int | float | str | None] + + +SENSORS: tuple[ArcamFmjSensorEntityDescription, ...] = ( + ArcamFmjSensorEntityDescription( + key="incoming_video_horizontal_resolution", + translation_key="incoming_video_horizontal_resolution", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="px", + suggested_display_precision=0, + value_fn=lambda state: ( + vp.horizontal_resolution + if (vp := state.get_incoming_video_parameters()) is not None + else None + ), + ), + ArcamFmjSensorEntityDescription( + key="incoming_video_vertical_resolution", + translation_key="incoming_video_vertical_resolution", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="px", + suggested_display_precision=0, + value_fn=lambda state: ( + vp.vertical_resolution + if (vp := state.get_incoming_video_parameters()) is not None + else None + ), + ), + ArcamFmjSensorEntityDescription( + key="incoming_video_refresh_rate", + translation_key="incoming_video_refresh_rate", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfFrequency.HERTZ, + suggested_display_precision=0, + value_fn=lambda state: ( + vp.refresh_rate + if (vp := state.get_incoming_video_parameters()) is not None + else None + ), + ), + ArcamFmjSensorEntityDescription( + key="incoming_video_aspect_ratio", + translation_key="incoming_video_aspect_ratio", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=[member.name.lower() for member in IncomingVideoAspectRatio], + value_fn=lambda state: ( + vp.aspect_ratio.name.lower() + if (vp := state.get_incoming_video_parameters()) is not None + else None + ), + ), + ArcamFmjSensorEntityDescription( + key="incoming_video_colorspace", + translation_key="incoming_video_colorspace", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=[member.name.lower() for member in IncomingVideoColorspace], + value_fn=lambda state: ( + vp.colorspace.name.lower() + if (vp := state.get_incoming_video_parameters()) is not None + else None + ), + ), + ArcamFmjSensorEntityDescription( + key="incoming_audio_format", + 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 + ), + ), + 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 + ), + ), + ArcamFmjSensorEntityDescription( + key="incoming_audio_sample_rate", + translation_key="incoming_audio_sample_rate", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfFrequency.HERTZ, + suggested_display_precision=0, + value_fn=lambda state: ( + None + if (sample_rate := state.get_incoming_audio_sample_rate()) == 0 + else sample_rate + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ArcamFmjConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Arcam FMJ sensors from a config entry.""" + coordinators = config_entry.runtime_data.coordinators + + entities: list[ArcamFmjSensorEntity] = [] + for coordinator in coordinators.values(): + entities.extend( + ArcamFmjSensorEntity(coordinator, description) for description in SENSORS + ) + async_add_entities(entities) + + +class ArcamFmjSensorEntity(ArcamFmjEntity, SensorEntity): + """Representation of an Arcam FMJ sensor.""" + + entity_description: ArcamFmjSensorEntityDescription + + @property + def native_value(self) -> int | float | str | None: + """Return the sensor value.""" + return self.entity_description.value_fn(self.coordinator.state) diff --git a/homeassistant/components/arcam_fmj/strings.json b/homeassistant/components/arcam_fmj/strings.json index 435a6971d5bb31..cad3708efa7aed 100644 --- a/homeassistant/components/arcam_fmj/strings.json +++ b/homeassistant/components/arcam_fmj/strings.json @@ -23,5 +23,121 @@ "trigger_type": { "turn_on": "{entity_name} was requested to turn on" } + }, + "entity": { + "binary_sensor": { + "incoming_video_interlaced": { + "name": "Incoming video interlaced" + } + }, + "sensor": { + "incoming_audio_config": { + "name": "Incoming audio configuration", + "state": { + "auro_10_1": "Auro 10.1", + "auro_11_1": "Auro 11.1", + "auro_13_1": "Auro 13.1", + "auro_2_2_2": "Auro 2.2.2", + "auro_5_0": "Auro 5.0", + "auro_5_1": "Auro 5.1", + "auro_8_0": "Auro 8.0", + "auro_9_1": "Auro 9.1", + "auro_quad": "Auro quad", + "dual_mono": "Dual mono", + "dual_mono_lfe": "Dual mono + LFE", + "mono": "Mono", + "mono_lfe": "Mono + LFE", + "stereo_center": "Stereo center", + "stereo_center_lfe": "Stereo center + LFE", + "stereo_center_surr_lr": "Stereo center surround L/R", + "stereo_center_surr_lr_back_lr": "Stereo center surround L/R back L/R", + "stereo_center_surr_lr_back_lr_lfe": "Stereo center surround L/R back L/R + LFE", + "stereo_center_surr_lr_back_matrix": "Stereo center surround L/R back matrix", + "stereo_center_surr_lr_back_matrix_lfe": "Stereo center surround L/R back matrix + LFE", + "stereo_center_surr_lr_back_mono": "Stereo center surround L/R back mono", + "stereo_center_surr_lr_back_mono_lfe": "Stereo center surround L/R back mono + LFE", + "stereo_center_surr_lr_lfe": "Stereo center surround L/R + LFE", + "stereo_center_surr_mono": "Stereo center surround mono", + "stereo_center_surr_mono_lfe": "Stereo center surround mono + LFE", + "stereo_downmix": "Stereo downmix", + "stereo_downmix_lfe": "Stereo downmix + LFE", + "stereo_lfe": "Stereo + LFE", + "stereo_only": "Stereo only", + "stereo_only_lo_ro": "Stereo only Lo/Ro", + "stereo_only_lo_ro_lfe": "Stereo only Lo/Ro + LFE", + "stereo_surr_lr": "Stereo surround L/R", + "stereo_surr_lr_back_lr": "Stereo surround L/R back L/R", + "stereo_surr_lr_back_lr_lfe": "Stereo surround L/R back L/R + LFE", + "stereo_surr_lr_back_matrix": "Stereo surround L/R back matrix", + "stereo_surr_lr_back_matrix_lfe": "Stereo surround L/R back matrix + LFE", + "stereo_surr_lr_back_mono": "Stereo surround L/R back mono", + "stereo_surr_lr_back_mono_lfe": "Stereo surround L/R back mono + LFE", + "stereo_surr_lr_lfe": "Stereo surround L/R + LFE", + "stereo_surr_mono": "Stereo surround mono", + "stereo_surr_mono_lfe": "Stereo surround mono + LFE", + "undetected": "Undetected", + "unknown": "Unknown" + } + }, + "incoming_audio_format": { + "name": "Incoming audio format", + "state": { + "analogue_direct": "Analogue direct", + "auro_3d": "Auro-3D", + "dolby_atmos": "Dolby Atmos", + "dolby_digital": "Dolby Digital", + "dolby_digital_ex": "Dolby Digital EX", + "dolby_digital_plus": "Dolby Digital Plus", + "dolby_digital_surround": "Dolby Digital Surround", + "dolby_digital_true_hd": "Dolby TrueHD", + "dts": "DTS", + "dts_96_24": "DTS 96/24", + "dts_core": "DTS Core", + "dts_es_discrete": "DTS-ES Discrete", + "dts_es_discrete_96_24": "DTS-ES Discrete 96/24", + "dts_es_matrix": "DTS-ES Matrix", + "dts_es_matrix_96_24": "DTS-ES Matrix 96/24", + "dts_hd_high_res_audio": "DTS-HD High Resolution Audio", + "dts_hd_master_audio": "DTS-HD Master Audio", + "dts_low_bit_rate": "DTS Low Bit Rate", + "dts_x": "DTS:X", + "imax_enhanced": "IMAX Enhanced", + "pcm": "PCM", + "pcm_zero": "PCM zero", + "undetected": "Undetected", + "unsupported": "Unsupported" + } + }, + "incoming_audio_sample_rate": { + "name": "Incoming audio sample rate" + }, + "incoming_video_aspect_ratio": { + "name": "Incoming video aspect ratio", + "state": { + "aspect_16_9": "16:9", + "aspect_4_3": "4:3", + "undefined": "Undefined" + } + }, + "incoming_video_colorspace": { + "name": "Incoming video colorspace", + "state": { + "dolby_vision": "Dolby Vision", + "hdr10": "HDR10", + "hdr10_plus": "HDR10+", + "hlg": "HLG", + "normal": "Normal" + } + }, + "incoming_video_horizontal_resolution": { + "name": "Incoming video horizontal resolution" + }, + "incoming_video_refresh_rate": { + "name": "Incoming video refresh rate" + }, + "incoming_video_vertical_resolution": { + "name": "Incoming video vertical resolution" + } + } } } diff --git a/homeassistant/components/assist_pipeline/select.py b/homeassistant/components/assist_pipeline/select.py index 0dabfc2336c7c0..dc6283ccedf94c 100644 --- a/homeassistant/components/assist_pipeline/select.py +++ b/homeassistant/components/assist_pipeline/select.py @@ -78,19 +78,13 @@ def __init__( index: int = 0, ) -> None: """Initialize a pipeline selector.""" - if index < 1: - # Keep compatibility - key_suffix = "" - placeholder = "" - else: - key_suffix = f"_{index + 1}" - placeholder = f" {index + 1}" - - self.entity_description = replace( - self.entity_description, - key=f"pipeline{key_suffix}", - translation_placeholders={"index": placeholder}, - ) + if index >= 1: + self.entity_description = replace( + self.entity_description, + key=f"pipeline_{index + 1}", + translation_key="pipeline_n", + translation_placeholders={"index": str(index + 1)}, + ) self._domain = domain self._unique_id_prefix = unique_id_prefix diff --git a/homeassistant/components/assist_pipeline/strings.json b/homeassistant/components/assist_pipeline/strings.json index adff3fdce2d89e..dcc75bd4b9bfb2 100644 --- a/homeassistant/components/assist_pipeline/strings.json +++ b/homeassistant/components/assist_pipeline/strings.json @@ -7,11 +7,17 @@ }, "select": { "pipeline": { - "name": "Assistant{index}", + "name": "Assistant", "state": { "preferred": "Preferred" } }, + "pipeline_n": { + "name": "Assistant {index}", + "state": { + "preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]" + } + }, "vad_sensitivity": { "name": "Finished speaking detection", "state": { diff --git a/homeassistant/components/atag/sensor.py b/homeassistant/components/atag/sensor.py index ca5bbd5e6140e2..48865503002e72 100644 --- a/homeassistant/components/atag/sensor.py +++ b/homeassistant/components/atag/sensor.py @@ -64,6 +64,6 @@ def native_value(self): return self.coordinator.atag.report[self._id].state @property - def icon(self): + def icon(self) -> str: """Return icon.""" return self.coordinator.atag.report[self._id].icon diff --git a/homeassistant/components/atag/water_heater.py b/homeassistant/components/atag/water_heater.py index 286857f17eb543..a409c3cecfa82f 100644 --- a/homeassistant/components/atag/water_heater.py +++ b/homeassistant/components/atag/water_heater.py @@ -37,15 +37,15 @@ class AtagWaterHeater(AtagEntity, WaterHeaterEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS @property - def current_temperature(self): + def current_temperature(self) -> float: """Return the current temperature.""" return self.coordinator.atag.dhw.temperature @property - def current_operation(self): + def current_operation(self) -> str: """Return current operation.""" operation = self.coordinator.atag.dhw.current_operation - return operation if operation in self.operation_list else STATE_OFF + return operation if operation in OPERATION_LIST else STATE_OFF async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -53,7 +53,7 @@ async def async_set_temperature(self, **kwargs: Any) -> None: self.async_write_ha_state() @property - def target_temperature(self): + def target_temperature(self) -> float: """Return the setpoint if water demand, otherwise return base temp (comfort level).""" return self.coordinator.atag.dhw.target_temperature diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 341eba6b4b1d25..93a540dcd186cf 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import cast -from aiohttp import ClientResponseError +from aiohttp import ClientError from yalexs.exceptions import AugustApiAIOHTTPError from yalexs.manager.exceptions import CannotConnect, InvalidAuth, RequireValidation from yalexs.manager.gateway import Config as YaleXSConfig @@ -13,7 +13,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import 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 device_registry as dr, issue_registry as ir from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, @@ -45,11 +50,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: AugustConfigEntry) -> bo august_gateway = AugustGateway(Path(hass.config.config_dir), session, oauth_session) try: await async_setup_august(hass, entry, august_gateway) + except OAuth2TokenRequestReauthError as err: + raise ConfigEntryAuthFailed from err except (RequireValidation, InvalidAuth) as err: raise ConfigEntryAuthFailed from err except TimeoutError as err: raise ConfigEntryNotReady("Timed out connecting to august api") from err - except (AugustApiAIOHTTPError, ClientResponseError, CannotConnect) as err: + except ( + AugustApiAIOHTTPError, + OAuth2TokenRequestError, + ClientError, + CannotConnect, + ) as err: raise ConfigEntryNotReady from err await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index a778e3e7f5d558..3bfdbb158599ec 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -30,5 +30,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.4"] + "requirements": ["yalexs==9.2.0", "yalexs-ble==3.3.0"] } diff --git a/homeassistant/components/aurora_abb_powerone/coordinator.py b/homeassistant/components/aurora_abb_powerone/coordinator.py index d38f0716b444dc..64859ddc372bda 100644 --- a/homeassistant/components/aurora_abb_powerone/coordinator.py +++ b/homeassistant/components/aurora_abb_powerone/coordinator.py @@ -61,7 +61,13 @@ def _update_data(self) -> dict[str, float]: frequency = self.client.measure(4) i_leak_dcdc = self.client.measure(6) i_leak_inverter = self.client.measure(7) + power_in_1 = self.client.measure(8) + power_in_2 = self.client.measure(9) temperature_c = self.client.measure(21) + voltage_in_1 = self.client.measure(23) + current_in_1 = self.client.measure(25) + voltage_in_2 = self.client.measure(26) + current_in_2 = self.client.measure(27) r_iso = self.client.measure(30) energy_wh = self.client.cumulated_energy(5) [alarm, *_] = self.client.alarms() @@ -87,7 +93,13 @@ def _update_data(self) -> dict[str, float]: data["grid_frequency"] = round(frequency, 1) data["i_leak_dcdc"] = i_leak_dcdc data["i_leak_inverter"] = i_leak_inverter + data["power_in_1"] = round(power_in_1, 1) + data["power_in_2"] = round(power_in_2, 1) data["temp"] = round(temperature_c, 1) + data["voltage_in_1"] = round(voltage_in_1, 1) + data["current_in_1"] = round(current_in_1, 1) + data["voltage_in_2"] = round(voltage_in_2, 1) + data["current_in_2"] = round(current_in_2, 1) data["r_iso"] = r_iso data["totalenergy"] = round(energy_wh / 1000, 2) data["alarm"] = alarm diff --git a/homeassistant/components/aurora_abb_powerone/sensor.py b/homeassistant/components/aurora_abb_powerone/sensor.py index d35d8a2d8cb952..fdc9172bba629d 100644 --- a/homeassistant/components/aurora_abb_powerone/sensor.py +++ b/homeassistant/components/aurora_abb_powerone/sensor.py @@ -68,6 +68,7 @@ entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfFrequency.HERTZ, state_class=SensorStateClass.MEASUREMENT, + translation_key="grid_frequency", entity_registry_enabled_default=False, ), SensorEntityDescription( @@ -88,6 +89,60 @@ translation_key="i_leak_inverter", entity_registry_enabled_default=False, ), + SensorEntityDescription( + key="power_in_1", + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + translation_key="power_in_1", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="power_in_2", + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + translation_key="power_in_2", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="voltage_in_1", + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + translation_key="voltage_in_1", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="current_in_1", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + translation_key="current_in_1", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="voltage_in_2", + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + translation_key="voltage_in_2", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="current_in_2", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + translation_key="current_in_2", + entity_registry_enabled_default=False, + ), SensorEntityDescription( key="alarm", device_class=SensorDeviceClass.ENUM, diff --git a/homeassistant/components/aurora_abb_powerone/strings.json b/homeassistant/components/aurora_abb_powerone/strings.json index 0a0b43dba91716..4b65177b4bf42e 100644 --- a/homeassistant/components/aurora_abb_powerone/strings.json +++ b/homeassistant/components/aurora_abb_powerone/strings.json @@ -24,9 +24,18 @@ "alarm": { "name": "Alarm status" }, + "current_in_1": { + "name": "String 1 current" + }, + "current_in_2": { + "name": "String 2 current" + }, "grid_current": { "name": "Grid current" }, + "grid_frequency": { + "name": "Grid frequency" + }, "grid_voltage": { "name": "Grid voltage" }, @@ -36,6 +45,12 @@ "i_leak_inverter": { "name": "Inverter leak current" }, + "power_in_1": { + "name": "String 1 power" + }, + "power_in_2": { + "name": "String 2 power" + }, "power_output": { "name": "Power output" }, @@ -44,6 +59,12 @@ }, "total_energy": { "name": "Total energy" + }, + "voltage_in_1": { + "name": "String 1 voltage" + }, + "voltage_in_2": { + "name": "String 2 voltage" } } } diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 7643219484a97d..c4695b81d688e8 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -121,40 +121,58 @@ "alarm_control_panel", "assist_satellite", "climate", + "cover", "device_tracker", + "door", "fan", + "garage_door", + "gate", "humidifier", "lawn_mower", "light", "lock", "media_player", + "motion", + "occupancy", "person", + "schedule", "siren", "switch", "vacuum", + "window", } _EXPERIMENTAL_TRIGGER_PLATFORMS = { "alarm_control_panel", "assist_satellite", - "binary_sensor", "button", "climate", "cover", "device_tracker", + "door", "fan", + "garage_door", + "gate", "humidifier", + "humidity", + "input_boolean", "lawn_mower", "light", "lock", "media_player", + "motion", + "occupancy", "person", + "remote", "scene", + "schedule", + "select", "siren", "switch", "text", "update", "vacuum", + "window", } @@ -363,8 +381,7 @@ async def trigger_service_handler( async def reload_service_handler(service_call: ServiceCall) -> None: """Remove all automations and load new ones from config.""" await async_get_blueprints(hass).async_reset_cache() - if (conf := await component.async_prepare_reload(skip_reset=True)) is None: - return + conf = await component.async_prepare_reload(skip_reset=True) if automation_id := service_call.data.get(CONF_ID): await _async_process_single_config(hass, conf, component, automation_id) else: diff --git a/homeassistant/components/autoskope/__init__.py b/homeassistant/components/autoskope/__init__.py new file mode 100644 index 00000000000000..a269976dc3503a --- /dev/null +++ b/homeassistant/components/autoskope/__init__.py @@ -0,0 +1,53 @@ +"""The Autoskope integration.""" + +from __future__ import annotations + +import aiohttp +from autoskope_client.api import AutoskopeApi +from autoskope_client.models import CannotConnect, InvalidAuth + +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_create_clientsession + +from .const import DEFAULT_HOST +from .coordinator import AutoskopeConfigEntry, AutoskopeDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.DEVICE_TRACKER] + + +async def async_setup_entry(hass: HomeAssistant, entry: AutoskopeConfigEntry) -> bool: + """Set up Autoskope from a config entry.""" + session = async_create_clientsession(hass, cookie_jar=aiohttp.CookieJar()) + + api = AutoskopeApi( + host=entry.data.get(CONF_HOST, DEFAULT_HOST), + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + session=session, + ) + + try: + await api.connect() + except InvalidAuth as err: + # Raise ConfigEntryError until reauth flow is implemented (then ConfigEntryAuthFailed) + raise ConfigEntryError( + "Authentication failed, please check credentials" + ) from err + except CannotConnect as err: + raise ConfigEntryNotReady("Could not connect to Autoskope API") from err + + coordinator = AutoskopeDataUpdateCoordinator(hass, api, 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: AutoskopeConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/autoskope/config_flow.py b/homeassistant/components/autoskope/config_flow.py new file mode 100644 index 00000000000000..3f141b4663f53f --- /dev/null +++ b/homeassistant/components/autoskope/config_flow.py @@ -0,0 +1,89 @@ +"""Config flow for the Autoskope integration.""" + +from __future__ import annotations + +from typing import Any + +from autoskope_client.api import AutoskopeApi +from autoskope_client.models import CannotConnect, InvalidAuth +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import section +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DEFAULT_HOST, DOMAIN, SECTION_ADVANCED_SETTINGS + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + vol.Required(SECTION_ADVANCED_SETTINGS): section( + vol.Schema( + { + vol.Required(CONF_HOST, default=DEFAULT_HOST): TextSelector( + TextSelectorConfig(type=TextSelectorType.URL) + ), + } + ), + {"collapsed": True}, + ), + } +) + + +class AutoskopeConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Autoskope.""" + + 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: + username = user_input[CONF_USERNAME].lower() + host = user_input[SECTION_ADVANCED_SETTINGS][CONF_HOST].lower() + + try: + cv.url(host) + except vol.Invalid: + errors["base"] = "invalid_url" + + if not errors: + 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: + return self.async_create_entry( + title=f"Autoskope ({username})", + data={ + CONF_USERNAME: username, + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_HOST: host, + }, + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/autoskope/const.py b/homeassistant/components/autoskope/const.py new file mode 100644 index 00000000000000..2bf4de7dbf9e1d --- /dev/null +++ b/homeassistant/components/autoskope/const.py @@ -0,0 +1,9 @@ +"""Constants for the Autoskope integration.""" + +from datetime import timedelta + +DOMAIN = "autoskope" + +DEFAULT_HOST = "https://portal.autoskope.de" +SECTION_ADVANCED_SETTINGS = "advanced_settings" +UPDATE_INTERVAL = timedelta(seconds=60) diff --git a/homeassistant/components/autoskope/coordinator.py b/homeassistant/components/autoskope/coordinator.py new file mode 100644 index 00000000000000..2c4e159396b779 --- /dev/null +++ b/homeassistant/components/autoskope/coordinator.py @@ -0,0 +1,60 @@ +"""Data update coordinator for the Autoskope integration.""" + +from __future__ import annotations + +import logging + +from autoskope_client.api import AutoskopeApi +from autoskope_client.models import CannotConnect, InvalidAuth, Vehicle + +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__) + + +type AutoskopeConfigEntry = ConfigEntry[AutoskopeDataUpdateCoordinator] + + +class AutoskopeDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Vehicle]]): + """Class to manage fetching Autoskope data.""" + + config_entry: AutoskopeConfigEntry + + def __init__( + self, hass: HomeAssistant, api: AutoskopeApi, entry: AutoskopeConfigEntry + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + config_entry=entry, + ) + self.api = api + + async def _async_update_data(self) -> dict[str, Vehicle]: + """Fetch data from API endpoint.""" + try: + vehicles = await self.api.get_vehicles() + return {vehicle.id: vehicle for vehicle in vehicles} + + except InvalidAuth: + # Attempt to re-authenticate using stored credentials + try: + await self.api.authenticate() + # Retry the request after successful re-authentication + vehicles = await self.api.get_vehicles() + return {vehicle.id: vehicle for vehicle in vehicles} + except InvalidAuth as reauth_err: + raise ConfigEntryAuthFailed( + f"Authentication failed: {reauth_err}" + ) from reauth_err + + except CannotConnect as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/autoskope/device_tracker.py b/homeassistant/components/autoskope/device_tracker.py new file mode 100644 index 00000000000000..228edfd444fac1 --- /dev/null +++ b/homeassistant/components/autoskope/device_tracker.py @@ -0,0 +1,145 @@ +"""Support for Autoskope device tracking.""" + +from __future__ import annotations + +from autoskope_client.constants import MANUFACTURER +from autoskope_client.models import Vehicle + +from homeassistant.components.device_tracker import SourceType, TrackerEntity +from homeassistant.core import HomeAssistant, callback +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.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import AutoskopeConfigEntry, AutoskopeDataUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AutoskopeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Autoskope device tracker entities.""" + coordinator: AutoskopeDataUpdateCoordinator = entry.runtime_data + tracked_vehicles: set[str] = set() + + @callback + def update_entities() -> None: + """Update entities based on coordinator data.""" + current_vehicles = set(coordinator.data.keys()) + vehicles_to_add = current_vehicles - tracked_vehicles + + if vehicles_to_add: + new_entities = [ + AutoskopeDeviceTracker(coordinator, vehicle_id) + for vehicle_id in vehicles_to_add + ] + tracked_vehicles.update(vehicles_to_add) + async_add_entities(new_entities) + + entry.async_on_unload(coordinator.async_add_listener(update_entities)) + update_entities() + + +class AutoskopeDeviceTracker( + CoordinatorEntity[AutoskopeDataUpdateCoordinator], TrackerEntity +): + """Representation of an Autoskope tracked device.""" + + _attr_has_entity_name = True + _attr_name: str | None = None + + def __init__( + self, coordinator: AutoskopeDataUpdateCoordinator, vehicle_id: str + ) -> None: + """Initialize the TrackerEntity.""" + super().__init__(coordinator) + self._vehicle_id = vehicle_id + self._attr_unique_id = vehicle_id + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if ( + self._vehicle_id in self.coordinator.data + and (device_entry := self.device_entry) is not None + and device_entry.name != self._vehicle_data.name + ): + device_registry = dr.async_get(self.hass) + device_registry.async_update_device( + device_entry.id, name=self._vehicle_data.name + ) + super()._handle_coordinator_update() + + @property + def device_info(self) -> DeviceInfo: + """Return device info for the vehicle.""" + vehicle = self.coordinator.data[self._vehicle_id] + return DeviceInfo( + identifiers={(DOMAIN, str(vehicle.id))}, + name=vehicle.name, + manufacturer=MANUFACTURER, + model=vehicle.model, + serial_number=vehicle.imei, + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return ( + super().available + and self.coordinator.data is not None + and self._vehicle_id in self.coordinator.data + ) + + @property + def _vehicle_data(self) -> Vehicle: + """Return the vehicle data for the current entity.""" + return self.coordinator.data[self._vehicle_id] + + @property + def latitude(self) -> float | None: + """Return latitude value of the device.""" + if (vehicle := self._vehicle_data) and vehicle.position: + return float(vehicle.position.latitude) + return None + + @property + def longitude(self) -> float | None: + """Return longitude value of the device.""" + if (vehicle := self._vehicle_data) and vehicle.position: + return float(vehicle.position.longitude) + return None + + @property + def source_type(self) -> SourceType: + """Return the source type of the device.""" + return SourceType.GPS + + @property + def location_accuracy(self) -> float: + """Return the location accuracy of the device in meters.""" + if (vehicle := self._vehicle_data) and vehicle.gps_quality: + if vehicle.gps_quality > 0: + # HDOP to estimated accuracy in meters + # HDOP of 1-2 = good (5-10m), 2-5 = moderate (10-25m), >5 = poor (>25m) + return float(max(5, int(vehicle.gps_quality * 5.0))) + return 0.0 + + @property + def icon(self) -> str: + """Return the icon based on the vehicle's activity.""" + if self._vehicle_id not in self.coordinator.data: + return "mdi:car-clock" + vehicle = self._vehicle_data + if vehicle.position: + if vehicle.position.park_mode: + return "mdi:car-brake-parking" + if vehicle.position.speed > 5: # Moving threshold: 5 km/h + return "mdi:car-arrow-right" + return "mdi:car" + return "mdi:car-clock" diff --git a/homeassistant/components/autoskope/manifest.json b/homeassistant/components/autoskope/manifest.json new file mode 100644 index 00000000000000..9c38ba6bcc28a4 --- /dev/null +++ b/homeassistant/components/autoskope/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "autoskope", + "name": "Autoskope", + "codeowners": ["@mcisk"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/autoskope", + "integration_type": "hub", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["autoskope_client==1.4.1"] +} diff --git a/homeassistant/components/autoskope/quality_scale.yaml b/homeassistant/components/autoskope/quality_scale.yaml new file mode 100644 index 00000000000000..c0af808b0996b4 --- /dev/null +++ b/homeassistant/components/autoskope/quality_scale.yaml @@ -0,0 +1,88 @@ +# + in comment indicates requirement for quality scale +# - in comment indicates issue to be fixed, not impacting quality scale +rules: + # Bronze + action-setup: + status: exempt + comment: | + Integration does not provide custom services. + 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 services. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + Integration does not provide custom services. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + 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. + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + Integration does not use discovery. Autoskope devices use NB-IoT/LTE-M (via IoT SIMs) and LoRaWAN. + discovery: + status: exempt + comment: | + Integration does not use discovery. Autoskope devices use NB-IoT/LTE-M (via IoT SIMs) and LoRaWAN. + 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: | + Only one entity type (device_tracker) is created, making this not applicable. + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: + status: todo + comment: | + Reconfiguration flow removed for initial PR, will be added in follow-up. + repair-issues: todo + stale-devices: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: + status: todo + comment: | + Integration needs to be added to .strict-typing file for full compliance. diff --git a/homeassistant/components/autoskope/strings.json b/homeassistant/components/autoskope/strings.json new file mode 100644 index 00000000000000..d3a05f9f286512 --- /dev/null +++ b/homeassistant/components/autoskope/strings.json @@ -0,0 +1,52 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_url": "Invalid URL", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "The password for your Autoskope account.", + "username": "The username for your Autoskope account." + }, + "description": "Enter your Autoskope credentials.", + "sections": { + "advanced_settings": { + "data": { + "host": "API endpoint" + }, + "data_description": { + "host": "The URL of your Autoskope API endpoint. Only change this if you use a white-label portal." + }, + "name": "Advanced settings" + } + }, + "title": "Connect to Autoskope" + } + } + }, + "issues": { + "cannot_connect": { + "description": "Home Assistant could not connect to the Autoskope API at {host}. Please check the connection details and ensure the API endpoint is reachable.\n\nError: {error}", + "title": "Failed to connect to Autoskope" + }, + "invalid_auth": { + "description": "Authentication with Autoskope failed for user {username}. Please re-authenticate the integration with the correct password.", + "title": "Invalid Autoskope authentication" + }, + "low_battery": { + "description": "The battery voltage for vehicle {vehicle_name} ({vehicle_id}) is low ({value}V). Consider checking or replacing the battery.", + "title": "Low vehicle battery ({vehicle_name})" + } + } +} diff --git a/homeassistant/components/aws_s3/__init__.py b/homeassistant/components/aws_s3/__init__.py index b709595ae4adc9..57f2a45f18380a 100644 --- a/homeassistant/components/aws_s3/__init__.py +++ b/homeassistant/components/aws_s3/__init__.py @@ -5,11 +5,10 @@ import logging from typing import cast -from aiobotocore.client import AioBaseClient as S3Client from aiobotocore.session import AioSession from botocore.exceptions import ClientError, ConnectionError, ParamValidationError -from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady @@ -21,9 +20,9 @@ DATA_BACKUP_AGENT_LISTENERS, DOMAIN, ) +from .coordinator import S3ConfigEntry, S3DataUpdateCoordinator -type S3ConfigEntry = ConfigEntry[S3Client] - +_PLATFORMS = (Platform.SENSOR,) _LOGGER = logging.getLogger(__name__) @@ -64,7 +63,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: S3ConfigEntry) -> bool: translation_key="cannot_connect", ) from err - entry.runtime_data = client + coordinator = S3DataUpdateCoordinator( + hass, + entry=entry, + client=client, + ) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator def notify_backup_listeners() -> None: for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): @@ -72,11 +77,16 @@ def notify_backup_listeners() -> None: entry.async_on_unload(entry.async_on_state_change(notify_backup_listeners)) + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + return True async def async_unload_entry(hass: HomeAssistant, entry: S3ConfigEntry) -> bool: """Unload a config entry.""" - client = entry.runtime_data - await client.__aexit__(None, None, None) + unload_ok = await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) + if not unload_ok: + return False + coordinator = entry.runtime_data + await coordinator.client.__aexit__(None, None, None) return True diff --git a/homeassistant/components/aws_s3/backup.py b/homeassistant/components/aws_s3/backup.py index 97e2baeec8d126..95b9ae671b4dfe 100644 --- a/homeassistant/components/aws_s3/backup.py +++ b/homeassistant/components/aws_s3/backup.py @@ -14,12 +14,14 @@ BackupAgent, BackupAgentError, BackupNotFound, + OnProgressCallback, suggested_filename, ) from homeassistant.core import HomeAssistant, callback from . import S3ConfigEntry -from .const import CONF_BUCKET, DATA_BACKUP_AGENT_LISTENERS, DOMAIN +from .const import CONF_BUCKET, CONF_PREFIX, DATA_BACKUP_AGENT_LISTENERS, DOMAIN +from .helpers import async_list_backups_from_s3 _LOGGER = logging.getLogger(__name__) CACHE_TTL = 300 @@ -93,12 +95,19 @@ class S3BackupAgent(BackupAgent): def __init__(self, hass: HomeAssistant, entry: S3ConfigEntry) -> None: """Initialize the S3 agent.""" super().__init__() - self._client = entry.runtime_data + self._client = entry.runtime_data.client self._bucket: str = entry.data[CONF_BUCKET] self.name = entry.title self.unique_id = entry.entry_id self._backup_cache: dict[str, AgentBackup] = {} self._cache_expiration = time() + self._prefix: str = entry.data.get(CONF_PREFIX, "") + + def _with_prefix(self, key: str) -> str: + """Add prefix to a key if configured.""" + if not self._prefix: + return key + return f"{self._prefix}/{key}" @handle_boto_errors async def async_download_backup( @@ -114,7 +123,9 @@ async def async_download_backup( backup = await self._find_backup_by_id(backup_id) tar_filename, _ = suggested_filenames(backup) - response = await self._client.get_object(Bucket=self._bucket, Key=tar_filename) + response = await self._client.get_object( + Bucket=self._bucket, Key=self._with_prefix(tar_filename) + ) return response["Body"].iter_chunks() async def async_upload_backup( @@ -122,6 +133,7 @@ async def async_upload_backup( *, open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], backup: AgentBackup, + on_progress: OnProgressCallback, **kwargs: Any, ) -> None: """Upload a backup. @@ -141,7 +153,7 @@ async def async_upload_backup( metadata_content = json.dumps(backup.as_dict()) await self._client.put_object( Bucket=self._bucket, - Key=metadata_filename, + Key=self._with_prefix(metadata_filename), Body=metadata_content, ) except BotoCoreError as err: @@ -168,7 +180,7 @@ async def _upload_simple( await self._client.put_object( Bucket=self._bucket, - Key=tar_filename, + Key=self._with_prefix(tar_filename), Body=bytes(file_data), ) @@ -185,7 +197,7 @@ async def _upload_multipart( _LOGGER.debug("Starting multipart upload for %s", tar_filename) multipart_upload = await self._client.create_multipart_upload( Bucket=self._bucket, - Key=tar_filename, + Key=self._with_prefix(tar_filename), ) upload_id = multipart_upload["UploadId"] try: @@ -215,7 +227,7 @@ async def _upload_multipart( ) part = await cast(Any, self._client).upload_part( Bucket=self._bucket, - Key=tar_filename, + Key=self._with_prefix(tar_filename), PartNumber=part_number, UploadId=upload_id, Body=part_data.tobytes(), @@ -243,7 +255,7 @@ async def _upload_multipart( ) part = await cast(Any, self._client).upload_part( Bucket=self._bucket, - Key=tar_filename, + Key=self._with_prefix(tar_filename), PartNumber=part_number, UploadId=upload_id, Body=remaining_data.tobytes(), @@ -252,7 +264,7 @@ async def _upload_multipart( await cast(Any, self._client).complete_multipart_upload( Bucket=self._bucket, - Key=tar_filename, + Key=self._with_prefix(tar_filename), UploadId=upload_id, MultipartUpload={"Parts": parts}, ) @@ -261,7 +273,7 @@ async def _upload_multipart( try: await self._client.abort_multipart_upload( Bucket=self._bucket, - Key=tar_filename, + Key=self._with_prefix(tar_filename), UploadId=upload_id, ) except BotoCoreError: @@ -282,8 +294,12 @@ async def async_delete_backup( tar_filename, metadata_filename = suggested_filenames(backup) # Delete both the backup file and its metadata file - await self._client.delete_object(Bucket=self._bucket, Key=tar_filename) - await self._client.delete_object(Bucket=self._bucket, Key=metadata_filename) + await self._client.delete_object( + Bucket=self._bucket, Key=self._with_prefix(tar_filename) + ) + await self._client.delete_object( + Bucket=self._bucket, Key=self._with_prefix(metadata_filename) + ) # Reset cache after successful deletion self._cache_expiration = time() @@ -316,35 +332,10 @@ async def _list_backups(self) -> dict[str, AgentBackup]: if time() <= self._cache_expiration: return self._backup_cache - backups = {} - paginator = self._client.get_paginator("list_objects_v2") - metadata_files: list[dict[str, Any]] = [] - async for page in paginator.paginate(Bucket=self._bucket): - metadata_files.extend( - obj - for obj in page.get("Contents", []) - if obj["Key"].endswith(".metadata.json") - ) - - for metadata_file in metadata_files: - try: - # Download and parse metadata file - metadata_response = await self._client.get_object( - Bucket=self._bucket, Key=metadata_file["Key"] - ) - metadata_content = await metadata_response["Body"].read() - metadata_json = json.loads(metadata_content) - except (BotoCoreError, json.JSONDecodeError) as err: - _LOGGER.warning( - "Failed to process metadata file %s: %s", - metadata_file["Key"], - err, - ) - continue - backup = AgentBackup.from_dict(metadata_json) - backups[backup.backup_id] = backup - - self._backup_cache = backups + backups_list = await async_list_backups_from_s3( + self._client, self._bucket, self._prefix + ) + self._backup_cache = {b.backup_id: b for b in backups_list} self._cache_expiration = time() + CACHE_TTL return self._backup_cache diff --git a/homeassistant/components/aws_s3/config_flow.py b/homeassistant/components/aws_s3/config_flow.py index a4de192e513ce6..cb9d363172a3b8 100644 --- a/homeassistant/components/aws_s3/config_flow.py +++ b/homeassistant/components/aws_s3/config_flow.py @@ -22,6 +22,7 @@ CONF_ACCESS_KEY_ID, CONF_BUCKET, CONF_ENDPOINT_URL, + CONF_PREFIX, CONF_SECRET_ACCESS_KEY, DEFAULT_ENDPOINT_URL, DESCRIPTION_AWS_S3_DOCS_URL, @@ -39,6 +40,7 @@ vol.Required(CONF_ENDPOINT_URL, default=DEFAULT_ENDPOINT_URL): TextSelector( config=TextSelectorConfig(type=TextSelectorType.URL) ), + vol.Optional(CONF_PREFIX, default=""): cv.string, } ) @@ -53,16 +55,20 @@ async def async_step_user( errors: dict[str, str] = {} if user_input is not None: - self._async_abort_entries_match( - { - CONF_BUCKET: user_input[CONF_BUCKET], - CONF_ENDPOINT_URL: user_input[CONF_ENDPOINT_URL], - } - ) + normalized_prefix = user_input.get(CONF_PREFIX, "").strip("/") + # Check for existing entries, treating missing prefix as empty + for entry in self._async_current_entries(include_ignore=False): + entry_prefix = (entry.data.get(CONF_PREFIX) or "").strip("/") + if ( + entry.data.get(CONF_BUCKET) == user_input[CONF_BUCKET] + and entry.data.get(CONF_ENDPOINT_URL) + == user_input[CONF_ENDPOINT_URL] + and entry_prefix == normalized_prefix + ): + return self.async_abort(reason="already_configured") - if not urlparse(user_input[CONF_ENDPOINT_URL]).hostname.endswith( - AWS_DOMAIN - ): + hostname = urlparse(user_input[CONF_ENDPOINT_URL]).hostname + if not hostname or not hostname.endswith(AWS_DOMAIN): errors[CONF_ENDPOINT_URL] = "invalid_endpoint_url" else: try: @@ -84,9 +90,18 @@ async def async_step_user( except ConnectionError: errors[CONF_ENDPOINT_URL] = "cannot_connect" else: - return self.async_create_entry( - title=user_input[CONF_BUCKET], data=user_input - ) + data = dict(user_input) + if not normalized_prefix: + # Do not persist empty optional values + data.pop(CONF_PREFIX, None) + else: + data[CONF_PREFIX] = normalized_prefix + + title = user_input[CONF_BUCKET] + if normalized_prefix: + title = f"{title} - {normalized_prefix}" + + return self.async_create_entry(title=title, data=data) return self.async_show_form( step_id="user", diff --git a/homeassistant/components/aws_s3/const.py b/homeassistant/components/aws_s3/const.py index a6863e6c38a744..b4eed69c4a9607 100644 --- a/homeassistant/components/aws_s3/const.py +++ b/homeassistant/components/aws_s3/const.py @@ -11,6 +11,7 @@ CONF_SECRET_ACCESS_KEY = "secret_access_key" CONF_ENDPOINT_URL = "endpoint_url" CONF_BUCKET = "bucket" +CONF_PREFIX = "prefix" AWS_DOMAIN = "amazonaws.com" DEFAULT_ENDPOINT_URL = f"https://s3.eu-central-1.{AWS_DOMAIN}/" diff --git a/homeassistant/components/aws_s3/coordinator.py b/homeassistant/components/aws_s3/coordinator.py new file mode 100644 index 00000000000000..08df1dd4520b71 --- /dev/null +++ b/homeassistant/components/aws_s3/coordinator.py @@ -0,0 +1,73 @@ +"""DataUpdateCoordinator for AWS S3.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging + +from aiobotocore.client import AioBaseClient as S3Client +from botocore.exceptions import BotoCoreError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_BUCKET, CONF_PREFIX, DOMAIN +from .helpers import async_list_backups_from_s3 + +SCAN_INTERVAL = timedelta(hours=6) + +type S3ConfigEntry = ConfigEntry[S3DataUpdateCoordinator] + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class SensorData: + """Class to represent sensor data.""" + + all_backups_size: int + + +class S3DataUpdateCoordinator(DataUpdateCoordinator[SensorData]): + """Class to manage fetching AWS S3 data from single endpoint.""" + + config_entry: S3ConfigEntry + client: S3Client + + def __init__( + self, + hass: HomeAssistant, + *, + entry: S3ConfigEntry, + client: S3Client, + ) -> None: + """Initialize AWS S3 data updater.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self.client = client + self._bucket: str = entry.data[CONF_BUCKET] + self._prefix: str = entry.data.get(CONF_PREFIX, "") + + async def _async_update_data(self) -> SensorData: + """Fetch data from AWS S3.""" + try: + backups = await async_list_backups_from_s3( + self.client, self._bucket, self._prefix + ) + except BotoCoreError as error: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="error_fetching_data", + ) from error + + all_backups_size = sum(b.size for b in backups) + return SensorData( + all_backups_size=all_backups_size, + ) diff --git a/homeassistant/components/aws_s3/diagnostics.py b/homeassistant/components/aws_s3/diagnostics.py new file mode 100644 index 00000000000000..85acf83816a9ed --- /dev/null +++ b/homeassistant/components/aws_s3/diagnostics.py @@ -0,0 +1,55 @@ +"""Diagnostics support for AWS S3.""" + +from __future__ import annotations + +import dataclasses +from typing import Any + +from homeassistant.components.backup import ( + DATA_MANAGER as BACKUP_DATA_MANAGER, + BackupManager, +) +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from .const import ( + CONF_ACCESS_KEY_ID, + CONF_BUCKET, + CONF_PREFIX, + CONF_SECRET_ACCESS_KEY, + DOMAIN, +) +from .coordinator import S3ConfigEntry +from .helpers import async_list_backups_from_s3 + +TO_REDACT = (CONF_ACCESS_KEY_ID, CONF_SECRET_ACCESS_KEY) + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: S3ConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + backup_manager: BackupManager = hass.data[BACKUP_DATA_MANAGER] + backups = await async_list_backups_from_s3( + coordinator.client, + bucket=entry.data[CONF_BUCKET], + prefix=entry.data.get(CONF_PREFIX, ""), + ) + + data = { + "coordinator_data": dataclasses.asdict(coordinator.data), + "config": { + **entry.data, + **entry.options, + }, + "backup_agents": [ + {"name": agent.name} + for agent in backup_manager.backup_agents.values() + if agent.domain == DOMAIN + ], + "backup": [backup.as_dict() for backup in backups], + } + + return async_redact_data(data, TO_REDACT) diff --git a/homeassistant/components/aws_s3/entity.py b/homeassistant/components/aws_s3/entity.py new file mode 100644 index 00000000000000..24f12934ae36db --- /dev/null +++ b/homeassistant/components/aws_s3/entity.py @@ -0,0 +1,33 @@ +"""Define the AWS S3 entity.""" + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONF_BUCKET, DOMAIN +from .coordinator import S3DataUpdateCoordinator + + +class S3Entity(CoordinatorEntity[S3DataUpdateCoordinator]): + """Defines a base AWS S3 entity.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: S3DataUpdateCoordinator, description: EntityDescription + ) -> None: + """Initialize an AWS S3 entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this AWS S3 device.""" + return DeviceInfo( + identifiers={(DOMAIN, self.coordinator.config_entry.entry_id)}, + name=f"Bucket {self.coordinator.config_entry.data[CONF_BUCKET]}", + manufacturer="AWS", + model="AWS S3", + entry_type=DeviceEntryType.SERVICE, + ) diff --git a/homeassistant/components/aws_s3/helpers.py b/homeassistant/components/aws_s3/helpers.py new file mode 100644 index 00000000000000..4a5af12a4c0338 --- /dev/null +++ b/homeassistant/components/aws_s3/helpers.py @@ -0,0 +1,63 @@ +"""Helpers for the AWS S3 integration.""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from aiobotocore.client import AioBaseClient as S3Client +from botocore.exceptions import BotoCoreError + +from homeassistant.components.backup import AgentBackup + +_LOGGER = logging.getLogger(__name__) + + +async def async_list_backups_from_s3( + client: S3Client, + bucket: str, + prefix: str, +) -> list[AgentBackup]: + """List backups from an S3 bucket by reading metadata files.""" + paginator = client.get_paginator("list_objects_v2") + metadata_files: list[dict[str, Any]] = [] + + list_kwargs: dict[str, Any] = {"Bucket": bucket} + if prefix: + list_kwargs["Prefix"] = prefix + "/" + + async for page in paginator.paginate(**list_kwargs): + metadata_files.extend( + obj + for obj in page.get("Contents", []) + if obj["Key"].endswith(".metadata.json") + ) + + backups: list[AgentBackup] = [] + for metadata_file in metadata_files: + try: + metadata_response = await client.get_object( + Bucket=bucket, Key=metadata_file["Key"] + ) + metadata_content = await metadata_response["Body"].read() + metadata_json = json.loads(metadata_content) + except (BotoCoreError, json.JSONDecodeError) as err: + _LOGGER.warning( + "Failed to process metadata file %s: %s", + metadata_file["Key"], + err, + ) + continue + try: + backup = AgentBackup.from_dict(metadata_json) + except (KeyError, TypeError, ValueError) as err: + _LOGGER.warning( + "Failed to parse metadata in file %s: %s", + metadata_file["Key"], + err, + ) + continue + backups.append(backup) + + return backups diff --git a/homeassistant/components/aws_s3/manifest.json b/homeassistant/components/aws_s3/manifest.json index 8ab65b5883a14b..b54c0d29423b09 100644 --- a/homeassistant/components/aws_s3/manifest.json +++ b/homeassistant/components/aws_s3/manifest.json @@ -3,9 +3,10 @@ "name": "AWS S3", "codeowners": ["@tomasbedrich"], "config_flow": true, + "dependencies": ["backup"], "documentation": "https://www.home-assistant.io/integrations/aws_s3", "integration_type": "service", - "iot_class": "cloud_push", + "iot_class": "cloud_polling", "loggers": ["aiobotocore"], "quality_scale": "bronze", "requirements": ["aiobotocore==2.21.1"] diff --git a/homeassistant/components/aws_s3/quality_scale.yaml b/homeassistant/components/aws_s3/quality_scale.yaml index 11093f4430f45d..49c3ea4e35c415 100644 --- a/homeassistant/components/aws_s3/quality_scale.yaml +++ b/homeassistant/components/aws_s3/quality_scale.yaml @@ -3,9 +3,7 @@ rules: action-setup: status: exempt comment: Integration does not register custom actions. - appropriate-polling: - status: exempt - comment: This integration does not poll. + appropriate-polling: done brands: done common-modules: done config-flow-test-coverage: done @@ -20,16 +18,14 @@ rules: entity-event-setup: status: exempt comment: Entities of this integration does not explicitly subscribe to events. - entity-unique-id: - status: exempt - comment: This integration does not have entities. - has-entity-name: - status: exempt - comment: This integration does not have entities. + entity-unique-id: done + has-entity-name: done runtime-data: done test-before-configure: done test-before-setup: done - unique-config-entry: done + unique-config-entry: + status: exempt + comment: Hassfest does not recognize the duplicate prevention logic. Duplicate entries are prevented by checking bucket, endpoint URL, and prefix in the config flow. # Silver action-exceptions: @@ -40,37 +36,27 @@ rules: status: exempt comment: This integration does not have an options flow. docs-installation-parameters: done - entity-unavailable: - status: exempt - comment: This integration does not have entities. + entity-unavailable: done integration-owner: done - log-when-unavailable: todo - parallel-updates: - status: exempt - comment: This integration does not poll. + log-when-unavailable: done + parallel-updates: done reauthentication-flow: todo test-coverage: done # Gold - devices: - status: exempt - comment: This integration does not have entities. - diagnostics: todo + devices: done + diagnostics: done discovery-update-info: status: exempt comment: S3 is a cloud service that is not discovered on the network. discovery: status: exempt comment: S3 is a cloud service that is not discovered on the network. - docs-data-update: - status: exempt - comment: This integration does not poll. + docs-data-update: done docs-examples: status: exempt comment: The integration extends core functionality and does not require examples. - docs-known-limitations: - status: exempt - comment: No known limitations. + docs-known-limitations: done docs-supported-devices: status: exempt comment: This integration does not support physical devices. @@ -81,19 +67,11 @@ rules: docs-use-cases: done dynamic-devices: status: exempt - comment: This integration does not have devices. - entity-category: - status: exempt - comment: This integration does not have entities. - entity-device-class: - status: exempt - comment: This integration does not have entities. - entity-disabled-by-default: - status: exempt - comment: This integration does not have entities. - entity-translations: - status: exempt - comment: This integration does not have entities. + comment: This integration has a fixed set of devices. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done exception-translations: done icon-translations: status: exempt @@ -104,7 +82,7 @@ rules: comment: There are no issues which can be repaired. stale-devices: status: exempt - comment: This integration does not have devices. + comment: This is a service type integration with a single device. # Platinum async-dependency: done diff --git a/homeassistant/components/aws_s3/sensor.py b/homeassistant/components/aws_s3/sensor.py new file mode 100644 index 00000000000000..95e742cb2d95ef --- /dev/null +++ b/homeassistant/components/aws_s3/sensor.py @@ -0,0 +1,66 @@ +"""Support for AWS S3 sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import EntityCategory, UnitOfInformation +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import S3ConfigEntry, SensorData +from .entity import S3Entity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class S3SensorEntityDescription(SensorEntityDescription): + """Describes an AWS S3 sensor entity.""" + + value_fn: Callable[[SensorData], StateType] + + +SENSORS: tuple[S3SensorEntityDescription, ...] = ( + S3SensorEntityDescription( + key="backups_size", + translation_key="backups_size", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.MEBIBYTES, + suggested_display_precision=0, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.all_backups_size, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: S3ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up AWS S3 sensor based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + S3SensorEntity(coordinator, description) for description in SENSORS + ) + + +class S3SensorEntity(S3Entity, SensorEntity): + """Defines an AWS S3 sensor entity.""" + + entity_description: S3SensorEntityDescription + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/aws_s3/strings.json b/homeassistant/components/aws_s3/strings.json index 8eb935355c2db7..1030ed67025178 100644 --- a/homeassistant/components/aws_s3/strings.json +++ b/homeassistant/components/aws_s3/strings.json @@ -15,22 +15,34 @@ "access_key_id": "Access key ID", "bucket": "Bucket name", "endpoint_url": "Endpoint URL", + "prefix": "Prefix", "secret_access_key": "Secret access key" }, "data_description": { "access_key_id": "Access key ID to connect to AWS S3 API", "bucket": "Bucket must already exist and be writable by the provided credentials.", "endpoint_url": "Endpoint URL provided to [Boto3 Session]({boto3_docs_url}). Region-specific [AWS S3 endpoints]({aws_s3_docs_url}) are available in their docs.", + "prefix": "Folder or prefix to store backups in, for example `backups`", "secret_access_key": "Secret access key to connect to AWS S3 API" }, "title": "Add AWS S3 bucket" } } }, + "entity": { + "sensor": { + "backups_size": { + "name": "Total size of backups" + } + } + }, "exceptions": { "cannot_connect": { "message": "Cannot connect to endpoint" }, + "error_fetching_data": { + "message": "Error fetching data" + }, "invalid_bucket_name": { "message": "Invalid bucket name" }, diff --git a/homeassistant/components/axis/hub/api.py b/homeassistant/components/axis/hub/api.py index f33e925929c27f..2bfce19bae5bba 100644 --- a/homeassistant/components/axis/hub/api.py +++ b/homeassistant/components/axis/hub/api.py @@ -15,7 +15,7 @@ CONF_USERNAME, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.aiohttp_client import async_get_clientsession from ..const import LOGGER from ..errors import AuthenticationRequired, CannotConnect @@ -26,7 +26,7 @@ async def get_axis_api( config: Mapping[str, Any], ) -> axis.AxisDevice: """Create a Axis device API.""" - session = get_async_client(hass, verify_ssl=False) + session = async_get_clientsession(hass, verify_ssl=False) api = axis.AxisDevice( Configuration( diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index d03d6a7b1f4d97..072d0378ec0305 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==66"], + "requirements": ["axis==67"], "ssdp": [ { "manufacturer": "AXIS" diff --git a/homeassistant/components/azure_storage/backup.py b/homeassistant/components/azure_storage/backup.py index 54fd069a11fd32..5a684bfcc77d3a 100644 --- a/homeassistant/components/azure_storage/backup.py +++ b/homeassistant/components/azure_storage/backup.py @@ -16,6 +16,7 @@ BackupAgent, BackupAgentError, BackupNotFound, + OnProgressCallback, suggested_filename, ) from homeassistant.core import HomeAssistant, callback @@ -129,6 +130,7 @@ async def async_upload_backup( *, open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], backup: AgentBackup, + on_progress: OnProgressCallback, **kwargs: Any, ) -> None: """Upload a backup.""" diff --git a/homeassistant/components/backblaze_b2/backup.py b/homeassistant/components/backblaze_b2/backup.py index 9e795434c25e42..ec92a41a5dce2c 100644 --- a/homeassistant/components/backblaze_b2/backup.py +++ b/homeassistant/components/backblaze_b2/backup.py @@ -17,6 +17,7 @@ BackupAgent, BackupAgentError, BackupNotFound, + OnProgressCallback, suggested_filename, ) from homeassistant.core import HomeAssistant, callback @@ -230,6 +231,7 @@ async def async_upload_backup( *, open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], backup: AgentBackup, + on_progress: OnProgressCallback, **kwargs: Any, ) -> None: """Upload a backup to Backblaze B2. diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index f3289d6e744e68..6ed4f1ac2d3625 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -17,6 +17,7 @@ BackupAgentError, BackupAgentPlatformProtocol, LocalBackupAgent, + OnProgressCallback, ) from .config import BackupConfig, CreateBackupParametersDict from .const import DATA_MANAGER, DOMAIN @@ -41,6 +42,7 @@ RestoreBackupEvent, RestoreBackupStage, RestoreBackupState, + UploadBackupEvent, WrittenBackup, ) from .models import AddonInfo, AgentBackup, BackupNotFound, Folder @@ -72,9 +74,11 @@ "LocalBackupAgent", "ManagerBackup", "NewBackup", + "OnProgressCallback", "RestoreBackupEvent", "RestoreBackupStage", "RestoreBackupState", + "UploadBackupEvent", "WrittenBackup", "async_get_manager", "suggested_filename", diff --git a/homeassistant/components/backup/agent.py b/homeassistant/components/backup/agent.py index 8093ac88338de9..afb4cbf1d184ac 100644 --- a/homeassistant/components/backup/agent.py +++ b/homeassistant/components/backup/agent.py @@ -14,6 +14,13 @@ from .models import AgentBackup, BackupAgentError +class OnProgressCallback(Protocol): + """Protocol for on_progress callback.""" + + def __call__(self, *, bytes_uploaded: int, **kwargs: Any) -> None: + """Report upload progress.""" + + class BackupAgentUnreachableError(BackupAgentError): """Raised when the agent can't reach its API.""" @@ -53,12 +60,14 @@ async def async_upload_backup( *, open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], backup: AgentBackup, + on_progress: OnProgressCallback, **kwargs: Any, ) -> None: """Upload a backup. :param open_stream: A function returning an async iterator that yields bytes. :param backup: Metadata about the backup that should be uploaded. + :param on_progress: A callback to report the number of uploaded bytes. """ @abc.abstractmethod diff --git a/homeassistant/components/backup/backup.py b/homeassistant/components/backup/backup.py index de2cfecb1a5b46..3396c7e103fabc 100644 --- a/homeassistant/components/backup/backup.py +++ b/homeassistant/components/backup/backup.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.hassio import is_hassio -from .agent import BackupAgent, LocalBackupAgent +from .agent import BackupAgent, LocalBackupAgent, OnProgressCallback from .const import DOMAIN, LOGGER from .models import AgentBackup, BackupNotFound from .util import read_backup, suggested_filename @@ -73,6 +73,7 @@ async def async_upload_backup( *, open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], backup: AgentBackup, + on_progress: OnProgressCallback, **kwargs: Any, ) -> None: """Upload a backup.""" diff --git a/homeassistant/components/backup/const.py b/homeassistant/components/backup/const.py index 3d6e6fc45b5777..131acf99a802ed 100644 --- a/homeassistant/components/backup/const.py +++ b/homeassistant/components/backup/const.py @@ -33,3 +33,5 @@ "home-assistant_v2.db", "home-assistant_v2.db-wal", ] + +SECURETAR_CREATE_VERSION = 2 diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index cba09a078c1a5c..520ea8ea38b4df 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -20,13 +20,9 @@ from typing import IO, TYPE_CHECKING, Any, Protocol, TypedDict, cast import aiohttp -from securetar import SecureTarFile, atomic_contents_add +from securetar import SecureTarArchive, atomic_contents_add -from homeassistant.backup_restore import ( - RESTORE_BACKUP_FILE, - RESTORE_BACKUP_RESULT_FILE, - password_to_key, -) +from homeassistant.backup_restore import RESTORE_BACKUP_FILE, RESTORE_BACKUP_RESULT_FILE from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( @@ -36,6 +32,7 @@ issue_registry as ir, start, ) +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.json import json_bytes from homeassistant.util import dt as dt_util, json as json_util from homeassistant.util.async_iterator import AsyncIteratorReader @@ -60,6 +57,7 @@ EXCLUDE_DATABASE_FROM_BACKUP, EXCLUDE_FROM_BACKUP, LOGGER, + SECURETAR_CREATE_VERSION, ) from .models import ( AddonInfo, @@ -81,6 +79,8 @@ validate_password_stream, ) +UPLOAD_PROGRESS_DEBOUNCE_SECONDS = 1 + @dataclass(frozen=True, kw_only=True, slots=True) class NewBackup: @@ -144,6 +144,7 @@ class CreateBackupStage(StrEnum): ADDONS = "addons" AWAIT_ADDON_RESTARTS = "await_addon_restarts" DOCKER_CONFIG = "docker_config" + CLEANING_UP = "cleaning_up" FINISHING_FILE = "finishing_file" FOLDERS = "folders" HOME_ASSISTANT = "home_assistant" @@ -255,6 +256,15 @@ class BlockedEvent(ManagerStateEvent): manager_state: BackupManagerState = BackupManagerState.BLOCKED +@dataclass(frozen=True, kw_only=True, slots=True) +class UploadBackupEvent(ManagerStateEvent): + """Backup agent upload progress event.""" + + agent_id: str + uploaded_bytes: int + total_bytes: int + + class BackupPlatformProtocol(Protocol): """Define the format that backup platforms can have.""" @@ -582,9 +592,50 @@ async def upload_backup_to_agent(agent_id: str) -> None: _backup = replace( backup, protected=should_encrypt, size=streamer.size() ) - await self.backup_agents[agent_id].async_upload_backup( + agent = self.backup_agents[agent_id] + + latest_uploaded_bytes = 0 + + @callback + def _emit_upload_progress() -> None: + """Emit the latest upload progress event.""" + self.async_on_backup_event( + UploadBackupEvent( + manager_state=self.state, + agent_id=agent_id, + uploaded_bytes=latest_uploaded_bytes, + total_bytes=_backup.size, + ) + ) + + upload_progress_debouncer: Debouncer[None] = Debouncer( + self.hass, + LOGGER, + cooldown=UPLOAD_PROGRESS_DEBOUNCE_SECONDS, + immediate=True, + function=_emit_upload_progress, + ) + + @callback + def on_upload_progress(*, bytes_uploaded: int, **kwargs: Any) -> None: + """Handle upload progress.""" + nonlocal latest_uploaded_bytes + latest_uploaded_bytes = bytes_uploaded + upload_progress_debouncer.async_schedule_call() + + await agent.async_upload_backup( open_stream=open_stream_func, backup=_backup, + on_progress=on_upload_progress, + ) + upload_progress_debouncer.async_cancel() + self.async_on_backup_event( + UploadBackupEvent( + manager_state=self.state, + agent_id=agent_id, + uploaded_bytes=_backup.size, + total_bytes=_backup.size, + ) ) if streamer: await streamer.wait() @@ -1240,6 +1291,13 @@ async def _async_finish_backup( ) # delete old backups more numerous than copies # try this regardless of agent errors above + self.async_on_backup_event( + CreateBackupEvent( + reason=None, + stage=CreateBackupStage.CLEANING_UP, + state=CreateBackupState.IN_PROGRESS, + ) + ) await delete_backups_exceeding_configured_count(self) finally: @@ -1377,9 +1435,10 @@ def async_on_backup_event( """Forward event to subscribers.""" if (current_state := self.state) != (new_state := event.manager_state): LOGGER.debug("Backup state: %s -> %s", current_state, new_state) - self.last_event = event - if not isinstance(event, (BlockedEvent, IdleEvent)): - self.last_action_event = event + if not isinstance(event, UploadBackupEvent): + self.last_event = event + if not isinstance(event, (BlockedEvent, IdleEvent)): + self.last_action_event = event for subscription in self._backup_event_subscriptions: subscription(event) @@ -1858,20 +1917,22 @@ def is_excluded_by_filter(path: PurePath) -> bool: return False - outer_secure_tarfile = SecureTarFile( - tar_file_path, "w", gzip=False, bufsize=BUF_SIZE - ) - with outer_secure_tarfile as outer_secure_tarfile_tarfile: + with SecureTarArchive( + tar_file_path, + "w", + bufsize=BUF_SIZE, + create_version=SECURETAR_CREATE_VERSION, + password=password, + ) as outer_secure_tarfile: raw_bytes = json_bytes(backup_data) fileobj = io.BytesIO(raw_bytes) tar_info = tarfile.TarInfo(name="./backup.json") tar_info.size = len(raw_bytes) tar_info.mtime = int(time.time()) - outer_secure_tarfile_tarfile.addfile(tar_info, fileobj=fileobj) - with outer_secure_tarfile.create_inner_tar( + outer_secure_tarfile.tar.addfile(tar_info, fileobj=fileobj) + with outer_secure_tarfile.create_tar( "./homeassistant.tar.gz", gzip=True, - key=password_to_key(password) if password is not None else None, ) as core_tar: atomic_contents_add( tar_file=core_tar, diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json index 7b128dbecd0d92..0c1db47c05f7da 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==2025.2.1"], + "requirements": ["cronsim==2.7", "securetar==2026.2.0"], "single_config_entry": true } diff --git a/homeassistant/components/backup/store.py b/homeassistant/components/backup/store.py index 17ef1d3a8fbc6e..94d09e0c53f2d5 100644 --- a/homeassistant/components/backup/store.py +++ b/homeassistant/components/backup/store.py @@ -29,12 +29,17 @@ class StoredBackupData(TypedDict): class _BackupStore(Store[StoredBackupData]): """Class to help storing backup data.""" + # Maximum version we support reading for forward compatibility. + # This allows reading data written by a newer HA version after downgrade. + _MAX_READABLE_VERSION = 2 + def __init__(self, hass: HomeAssistant) -> None: """Initialize storage class.""" super().__init__( hass, STORAGE_VERSION, STORAGE_KEY, + max_readable_version=self._MAX_READABLE_VERSION, minor_version=STORAGE_VERSION_MINOR, ) @@ -86,8 +91,8 @@ async def _async_migrate_func( # data["config"]["schedule"]["state"] will be removed. The bump to 2 is # planned to happen after a 6 month quiet period with no minor version # changes. - # Reject if major version is higher than 2. - if old_major_version > 2: + # Reject if major version is higher than _MAX_READABLE_VERSION. + if old_major_version > self._MAX_READABLE_VERSION: raise NotImplementedError return data diff --git a/homeassistant/components/backup/strings.json b/homeassistant/components/backup/strings.json index 2562c704ee0b24..c61122d43113eb 100644 --- a/homeassistant/components/backup/strings.json +++ b/homeassistant/components/backup/strings.json @@ -43,11 +43,11 @@ "title": "The backup location {agent_id} is unavailable" }, "automatic_backup_failed_addons": { - "description": "Add-ons {failed_addons} could not be included in automatic backup. Please check the Supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured.", - "title": "Not all add-ons could be included in automatic backup" + "description": "Apps {failed_addons} could not be included in automatic backup. Please check the Supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured.", + "title": "Not all apps could be included in automatic backup" }, "automatic_backup_failed_agents_addons_folders": { - "description": "The automatic backup was created with errors:\n* Locations which the backup could not be uploaded to: {failed_agents}\n* Add-ons which could not be backed up: {failed_addons}\n* Folders which could not be backed up: {failed_folders}\n\nPlease check the Core and Supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured.", + "description": "The automatic backup was created with errors:\n* Locations which the backup could not be uploaded to: {failed_agents}\n* Apps which could not be backed up: {failed_addons}\n* Folders which could not be backed up: {failed_folders}\n\nPlease check the Core and Supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured.", "title": "Automatic backup was created with errors" }, "automatic_backup_failed_create": { diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index 9dfcb36783d104..d93290d675cba1 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -8,7 +8,6 @@ from dataclasses import dataclass, replace from io import BytesIO import json -import os from pathlib import Path, PurePath from queue import SimpleQueue import tarfile @@ -16,9 +15,15 @@ from typing import IO, Any, cast import aiohttp -from securetar import SecureTarError, SecureTarFile, SecureTarReadError +from securetar import ( + InvalidPasswordError, + SecureTarArchive, + SecureTarError, + SecureTarFile, + SecureTarReadError, + SecureTarRootKeyContext, +) -from homeassistant.backup_restore import password_to_key from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.util import dt as dt_util @@ -29,7 +34,7 @@ ) from homeassistant.util.json import JsonObjectType, json_loads_object -from .const import BUF_SIZE, LOGGER +from .const import BUF_SIZE, LOGGER, SECURETAR_CREATE_VERSION from .models import AddonInfo, AgentBackup, Folder @@ -132,17 +137,23 @@ def suggested_filename(backup: AgentBackup) -> str: def validate_password(path: Path, password: str | None) -> bool: - """Validate the password.""" - with tarfile.open(path, "r:", bufsize=BUF_SIZE) as backup_file: + """Validate the password. + + This assumes every inner tar is encrypted with the same secure tar version and + same password. + """ + with SecureTarArchive( + path, "r", bufsize=BUF_SIZE, password=password + ) as backup_file: compressed = False ha_tar_name = "homeassistant.tar" try: - ha_tar = backup_file.extractfile(ha_tar_name) + ha_tar = backup_file.tar.extractfile(ha_tar_name) except KeyError: compressed = True ha_tar_name = "homeassistant.tar.gz" try: - ha_tar = backup_file.extractfile(ha_tar_name) + ha_tar = backup_file.tar.extractfile(ha_tar_name) except KeyError: LOGGER.error("No homeassistant.tar or homeassistant.tar.gz found") return False @@ -150,13 +161,12 @@ def validate_password(path: Path, password: str | None) -> bool: with SecureTarFile( path, # Not used gzip=compressed, - key=password_to_key(password) if password is not None else None, - mode="r", + password=password, fileobj=ha_tar, ): # If we can read the tar file, the password is correct return True - except tarfile.ReadError: + except tarfile.ReadError, InvalidPasswordError, SecureTarReadError: LOGGER.debug("Invalid password") return False except Exception: # noqa: BLE001 @@ -168,27 +178,29 @@ def validate_password_stream( input_stream: IO[bytes], password: str | None, ) -> None: - """Decrypt a backup.""" - with ( - tarfile.open(fileobj=input_stream, mode="r|", bufsize=BUF_SIZE) as input_tar, - ): - for obj in input_tar: + """Validate the password. + + This assumes every inner tar is encrypted with the same secure tar version and + same password. + """ + with SecureTarArchive( + fileobj=input_stream, + mode="r", + bufsize=BUF_SIZE, + streaming=True, + password=password, + ) as input_archive: + for obj in input_archive.tar: if not obj.name.endswith((".tar", ".tgz", ".tar.gz")): continue - istf = SecureTarFile( - None, # Not used - gzip=False, - key=password_to_key(password) if password is not None else None, - mode="r", - fileobj=input_tar.extractfile(obj), - ) - with istf.decrypt(obj) as decrypted: - if istf.securetar_header.plaintext_size is None: - raise UnsupportedSecureTarVersion - try: + try: + with input_archive.extract_tar(obj) as decrypted: + if decrypted.plaintext_size is None: + raise UnsupportedSecureTarVersion decrypted.read(1) # Read a single byte to trigger the decryption - except SecureTarReadError as err: - raise IncorrectPassword from err + except (InvalidPasswordError, SecureTarReadError) as err: + raise IncorrectPassword from err + else: return raise BackupEmpty @@ -212,24 +224,30 @@ def decrypt_backup( password: str | None, on_done: Callable[[Exception | None], None], minimum_size: int, - nonces: NonceGenerator, + key_context: SecureTarRootKeyContext, ) -> None: """Decrypt a backup.""" error: Exception | None = None try: try: with ( - tarfile.open( - fileobj=input_stream, mode="r|", bufsize=BUF_SIZE - ) as input_tar, + SecureTarArchive( + fileobj=input_stream, + mode="r", + bufsize=BUF_SIZE, + streaming=True, + password=password, + ) as input_archive, tarfile.open( fileobj=output_stream, mode="w|", bufsize=BUF_SIZE ) as output_tar, ): - _decrypt_backup(backup, input_tar, output_tar, password) + _decrypt_backup(backup, input_archive, output_tar) except (DecryptError, SecureTarError, tarfile.TarError) as err: LOGGER.warning("Error decrypting backup: %s", err) error = err + except Abort: + raise except Exception as err: # noqa: BLE001 LOGGER.exception("Unexpected error when decrypting backup: %s", err) error = err @@ -248,19 +266,18 @@ def decrypt_backup( def _decrypt_backup( backup: AgentBackup, - input_tar: tarfile.TarFile, + input_archive: SecureTarArchive, output_tar: tarfile.TarFile, - password: str | None, ) -> None: """Decrypt a backup.""" expected_archives = _get_expected_archives(backup) - for obj in input_tar: + for obj in input_archive.tar: # We compare with PurePath to avoid issues with different path separators, # for example when backup.json is added as "./backup.json" object_path = PurePath(obj.name) if object_path == PurePath("backup.json"): # Rewrite the backup.json file to indicate that the backup is decrypted - if not (reader := input_tar.extractfile(obj)): + if not (reader := input_archive.tar.extractfile(obj)): raise DecryptError metadata = json_loads_object(reader.read()) metadata["protected"] = False @@ -272,21 +289,15 @@ def _decrypt_backup( prefix, _, suffix = object_path.name.partition(".") if suffix not in ("tar", "tgz", "tar.gz"): LOGGER.debug("Unknown file %s will not be decrypted", obj.name) - output_tar.addfile(obj, input_tar.extractfile(obj)) + output_tar.addfile(obj, input_archive.tar.extractfile(obj)) continue if prefix not in expected_archives: LOGGER.debug("Unknown inner tar file %s will not be decrypted", obj.name) - output_tar.addfile(obj, input_tar.extractfile(obj)) + output_tar.addfile(obj, input_archive.tar.extractfile(obj)) continue - istf = SecureTarFile( - None, # Not used - gzip=False, - key=password_to_key(password) if password is not None else None, - mode="r", - fileobj=input_tar.extractfile(obj), - ) - with istf.decrypt(obj) as decrypted: - if (plaintext_size := istf.securetar_header.plaintext_size) is None: + with input_archive.extract_tar(obj) as decrypted: + # Guard against SecureTar v1 which doesn't store plaintext size + if (plaintext_size := decrypted.plaintext_size) is None: raise UnsupportedSecureTarVersion decrypted_obj = copy.deepcopy(obj) decrypted_obj.size = plaintext_size @@ -300,7 +311,7 @@ def encrypt_backup( password: str | None, on_done: Callable[[Exception | None], None], minimum_size: int, - nonces: NonceGenerator, + key_context: SecureTarRootKeyContext, ) -> None: """Encrypt a backup.""" error: Exception | None = None @@ -310,16 +321,23 @@ def encrypt_backup( tarfile.open( fileobj=input_stream, mode="r|", bufsize=BUF_SIZE ) as input_tar, - tarfile.open( - fileobj=output_stream, mode="w|", bufsize=BUF_SIZE - ) as output_tar, + SecureTarArchive( + fileobj=output_stream, + mode="w", + bufsize=BUF_SIZE, + streaming=True, + root_key_context=key_context, + create_version=SECURETAR_CREATE_VERSION, + ) as output_archive, ): - _encrypt_backup(backup, input_tar, output_tar, password, nonces) + _encrypt_backup(backup, input_tar, output_archive) except (EncryptError, SecureTarError, tarfile.TarError) as err: LOGGER.warning("Error encrypting backup: %s", err) error = err + except Abort: + raise except Exception as err: # noqa: BLE001 - LOGGER.exception("Unexpected error when decrypting backup: %s", err) + LOGGER.exception("Unexpected error when encrypting backup: %s", err) error = err else: # Pad the output stream to the requested minimum size @@ -337,9 +355,7 @@ def encrypt_backup( def _encrypt_backup( backup: AgentBackup, input_tar: tarfile.TarFile, - output_tar: tarfile.TarFile, - password: str | None, - nonces: NonceGenerator, + output_archive: SecureTarArchive, ) -> None: """Encrypt a backup.""" inner_tar_idx = 0 @@ -357,29 +373,20 @@ def _encrypt_backup( updated_metadata_b = json.dumps(metadata).encode() metadata_obj = copy.deepcopy(obj) metadata_obj.size = len(updated_metadata_b) - output_tar.addfile(metadata_obj, BytesIO(updated_metadata_b)) + output_archive.tar.addfile(metadata_obj, BytesIO(updated_metadata_b)) continue prefix, _, suffix = object_path.name.partition(".") if suffix not in ("tar", "tgz", "tar.gz"): LOGGER.debug("Unknown file %s will not be encrypted", obj.name) - output_tar.addfile(obj, input_tar.extractfile(obj)) + output_archive.tar.addfile(obj, input_tar.extractfile(obj)) continue if prefix not in expected_archives: LOGGER.debug("Unknown inner tar file %s will not be encrypted", obj.name) continue - istf = SecureTarFile( - None, # Not used - gzip=False, - key=password_to_key(password) if password is not None else None, - mode="r", - fileobj=input_tar.extractfile(obj), - nonce=nonces.get(inner_tar_idx), + output_archive.import_tar( + input_tar.extractfile(obj), obj, derived_key_id=inner_tar_idx ) inner_tar_idx += 1 - with istf.encrypt(obj) as encrypted: - encrypted_obj = copy.deepcopy(obj) - encrypted_obj.size = encrypted.encrypted_size - output_tar.addfile(encrypted_obj, encrypted) @dataclass(kw_only=True) @@ -391,21 +398,6 @@ class _CipherWorkerStatus: writer: AsyncIteratorWriter -class NonceGenerator: - """Generate nonces for encryption.""" - - def __init__(self) -> None: - """Initialize the generator.""" - self._nonces: dict[int, bytes] = {} - - def get(self, index: int) -> bytes: - """Get a nonce for the given index.""" - if index not in self._nonces: - # Generate a new nonce for the given index - self._nonces[index] = os.urandom(16) - return self._nonces[index] - - class _CipherBackupStreamer: """Encrypt or decrypt a backup.""" @@ -417,7 +409,7 @@ class _CipherBackupStreamer: str | None, Callable[[Exception | None], None], int, - NonceGenerator, + SecureTarRootKeyContext, ], None, ] @@ -435,7 +427,7 @@ def __init__( self._hass = hass self._open_stream = open_stream self._password = password - self._nonces = NonceGenerator() + self._key_context = SecureTarRootKeyContext(password) def size(self) -> int: """Return the maximum size of the decrypted or encrypted backup.""" @@ -466,7 +458,7 @@ def on_done(error: Exception | None) -> None: self._password, on_done, self.size(), - self._nonces, + self._key_context, ], ) worker_status = _CipherWorkerStatus( diff --git a/homeassistant/components/binary_sensor/icons.json b/homeassistant/components/binary_sensor/icons.json index 966e2adb5a1480..929ca8114e37fb 100644 --- a/homeassistant/components/binary_sensor/icons.json +++ b/homeassistant/components/binary_sensor/icons.json @@ -174,13 +174,5 @@ "on": "mdi:window-open" } } - }, - "triggers": { - "occupancy_cleared": { - "trigger": "mdi:home-outline" - }, - "occupancy_detected": { - "trigger": "mdi:home" - } } } diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json index 989555994a6523..08d16fd03966a4 100644 --- a/homeassistant/components/binary_sensor/strings.json +++ b/homeassistant/components/binary_sensor/strings.json @@ -1,8 +1,4 @@ { - "common": { - "trigger_behavior_description_occupancy": "The behavior of the targeted occupancy sensors to trigger on.", - "trigger_behavior_name": "Behavior" - }, "device_automation": { "condition_type": { "is_bat_low": "{entity_name} battery is low", @@ -321,36 +317,5 @@ } } }, - "selector": { - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, - "title": "Binary sensor", - "triggers": { - "occupancy_cleared": { - "description": "Triggers after one or more occupancy sensors stop detecting occupancy.", - "fields": { - "behavior": { - "description": "[%key:component::binary_sensor::common::trigger_behavior_description_occupancy%]", - "name": "[%key:component::binary_sensor::common::trigger_behavior_name%]" - } - }, - "name": "Occupancy cleared" - }, - "occupancy_detected": { - "description": "Triggers after one or more occupancy sensors start detecting occupancy.", - "fields": { - "behavior": { - "description": "[%key:component::binary_sensor::common::trigger_behavior_description_occupancy%]", - "name": "[%key:component::binary_sensor::common::trigger_behavior_name%]" - } - }, - "name": "Occupancy detected" - } - } + "title": "Binary sensor" } diff --git a/homeassistant/components/binary_sensor/trigger.py b/homeassistant/components/binary_sensor/trigger.py deleted file mode 100644 index 4dfee30b2c2bb1..00000000000000 --- a/homeassistant/components/binary_sensor/trigger.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Provides triggers for binary sensors.""" - -from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import get_device_class -from homeassistant.helpers.trigger import EntityTargetStateTriggerBase, Trigger -from homeassistant.helpers.typing import UNDEFINED, UndefinedType - -from . import DOMAIN, BinarySensorDeviceClass - - -def get_device_class_or_undefined( - hass: HomeAssistant, entity_id: str -) -> str | None | UndefinedType: - """Get the device class of an entity or UNDEFINED if not found.""" - try: - return get_device_class(hass, entity_id) - except HomeAssistantError: - return UNDEFINED - - -class BinarySensorOnOffTrigger(EntityTargetStateTriggerBase): - """Class for binary sensor on/off triggers.""" - - _device_class: BinarySensorDeviceClass | None - _domain: str = DOMAIN - - def entity_filter(self, entities: set[str]) -> set[str]: - """Filter entities of this domain.""" - entities = super().entity_filter(entities) - return { - entity_id - for entity_id in entities - if get_device_class_or_undefined(self._hass, entity_id) - == self._device_class - } - - -def make_binary_sensor_trigger( - device_class: BinarySensorDeviceClass | None, - to_state: str, -) -> type[BinarySensorOnOffTrigger]: - """Create an entity state trigger class.""" - - class CustomTrigger(BinarySensorOnOffTrigger): - """Trigger for entity state changes.""" - - _device_class = device_class - _to_states = {to_state} - - return CustomTrigger - - -TRIGGERS: dict[str, type[Trigger]] = { - "occupancy_detected": make_binary_sensor_trigger( - BinarySensorDeviceClass.OCCUPANCY, STATE_ON - ), - "occupancy_cleared": make_binary_sensor_trigger( - BinarySensorDeviceClass.OCCUPANCY, STATE_OFF - ), -} - - -async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: - """Return the triggers for binary sensors.""" - return TRIGGERS diff --git a/homeassistant/components/bitcoin/sensor.py b/homeassistant/components/bitcoin/sensor.py index cb7bc5a043b27d..f350c1c0b58b1e 100644 --- a/homeassistant/components/bitcoin/sensor.py +++ b/homeassistant/components/bitcoin/sensor.py @@ -190,7 +190,7 @@ def update(self) -> None: elif sensor_type == "miners_revenue_usd": self._attr_native_value = f"{stats.miners_revenue_usd:.0f}" elif sensor_type == "btc_mined": - self._attr_native_value = str(stats.btc_mined * 0.00000001) + self._attr_native_value = str(stats.btc_mined * 1e-8) elif sensor_type == "trade_volume_usd": self._attr_native_value = f"{stats.trade_volume_usd:.1f}" elif sensor_type == "difficulty": @@ -208,13 +208,13 @@ def update(self) -> None: elif sensor_type == "blocks_size": self._attr_native_value = f"{stats.blocks_size:.1f}" elif sensor_type == "total_fees_btc": - self._attr_native_value = f"{stats.total_fees_btc * 0.00000001:.2f}" + self._attr_native_value = f"{stats.total_fees_btc * 1e-8:.2f}" elif sensor_type == "total_btc_sent": - self._attr_native_value = f"{stats.total_btc_sent * 0.00000001:.2f}" + self._attr_native_value = f"{stats.total_btc_sent * 1e-8:.2f}" elif sensor_type == "estimated_btc_sent": - self._attr_native_value = f"{stats.estimated_btc_sent * 0.00000001:.2f}" + self._attr_native_value = f"{stats.estimated_btc_sent * 1e-8:.2f}" elif sensor_type == "total_btc": - self._attr_native_value = f"{stats.total_btc * 0.00000001:.2f}" + self._attr_native_value = f"{stats.total_btc * 1e-8:.2f}" elif sensor_type == "total_blocks": self._attr_native_value = f"{stats.total_blocks:.0f}" elif sensor_type == "next_retarget": @@ -222,7 +222,7 @@ def update(self) -> None: elif sensor_type == "estimated_transaction_volume_usd": self._attr_native_value = f"{stats.estimated_transaction_volume_usd:.2f}" elif sensor_type == "miners_revenue_btc": - self._attr_native_value = f"{stats.miners_revenue_btc * 0.00000001:.1f}" + self._attr_native_value = f"{stats.miners_revenue_btc * 1e-8:.1f}" elif sensor_type == "market_price_usd": self._attr_native_value = f"{stats.market_price_usd:.2f}" diff --git a/homeassistant/components/blebox/light.py b/homeassistant/components/blebox/light.py index 75900ca7d97ba7..4db64d998f53f9 100644 --- a/homeassistant/components/blebox/light.py +++ b/homeassistant/components/blebox/light.py @@ -74,7 +74,7 @@ def is_on(self) -> bool: return self._feature.is_on @property - def brightness(self): + def brightness(self) -> int | None: """Return the name.""" return self._feature.brightness diff --git a/homeassistant/components/blebox/switch.py b/homeassistant/components/blebox/switch.py index 1598d4db6fa4d0..c0f9d9a5e4b3a5 100644 --- a/homeassistant/components/blebox/switch.py +++ b/homeassistant/components/blebox/switch.py @@ -34,7 +34,7 @@ class BleBoxSwitchEntity(BleBoxEntity[blebox_uniapi.switch.Switch], SwitchEntity _attr_device_class = SwitchDeviceClass.SWITCH @property - def is_on(self): + def is_on(self) -> bool | None: """Return whether switch is on.""" return self._feature.is_on diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index f8de9203f4ad5e..fd09be71601852 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -85,6 +85,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity _attr_media_content_type = MediaType.MUSIC _attr_has_entity_name = True _attr_name = None + _attr_volume_step = 0.01 def __init__( self, @@ -688,24 +689,6 @@ async def async_play_media( await self._player.play_url(url) - async def async_volume_up(self) -> None: - """Volume up the media player.""" - if self.volume_level is None: - return - - new_volume = self.volume_level + 0.01 - new_volume = min(1, new_volume) - await self.async_set_volume_level(new_volume) - - async def async_volume_down(self) -> None: - """Volume down the media player.""" - if self.volume_level is None: - return - - new_volume = self.volume_level - 0.01 - new_volume = max(0, new_volume) - await self.async_set_volume_level(new_volume) - async def async_set_volume_level(self, volume: float) -> None: """Send volume_up command to media player.""" volume = int(round(volume * 100)) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index e71f881b1f9d84..62fef359b0d44c 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -16,11 +16,11 @@ "quality_scale": "internal", "requirements": [ "bleak==2.1.1", - "bleak-retry-connector==4.4.3", + "bleak-retry-connector==4.6.0", "bluetooth-adapters==2.1.0", "bluetooth-auto-recovery==1.5.3", "bluetooth-data-tools==1.28.4", "dbus-fast==3.1.2", - "habluetooth==5.8.0" + "habluetooth==5.10.2" ] } diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py deleted file mode 100644 index 287cb226b51f4c..00000000000000 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Reads vehicle status from MyBMW portal.""" - -from __future__ import annotations - -import logging - -import voluptuous as vol - -from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITY_ID, CONF_NAME, Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import ( - config_validation as cv, - device_registry as dr, - discovery, - entity_registry as er, -) - -from .const import ATTR_VIN, CONF_READ_ONLY, DOMAIN -from .coordinator import BMWConfigEntry, BMWDataUpdateCoordinator - -_LOGGER = logging.getLogger(__name__) - - -SERVICE_SCHEMA = vol.Schema( - vol.Any( - {vol.Required(ATTR_VIN): cv.string}, - {vol.Required(CONF_DEVICE_ID): cv.string}, - ) -) - -DEFAULT_OPTIONS = { - CONF_READ_ONLY: False, -} - -PLATFORMS = [ - Platform.BINARY_SENSOR, - Platform.BUTTON, - Platform.DEVICE_TRACKER, - Platform.LOCK, - Platform.NOTIFY, - Platform.NUMBER, - Platform.SELECT, - Platform.SENSOR, - Platform.SWITCH, -] - -SERVICE_UPDATE_STATE = "update_state" - - -@callback -def _async_migrate_options_from_data_if_missing( - hass: HomeAssistant, entry: BMWConfigEntry -) -> None: - data = dict(entry.data) - options = dict(entry.options) - - if CONF_READ_ONLY in data or list(options) != list(DEFAULT_OPTIONS): - options = dict( - DEFAULT_OPTIONS, - **{k: v for k, v in options.items() if k in DEFAULT_OPTIONS}, - ) - options[CONF_READ_ONLY] = data.pop(CONF_READ_ONLY, False) - - hass.config_entries.async_update_entry(entry, data=data, options=options) - - -async def _async_migrate_entries( - hass: HomeAssistant, config_entry: BMWConfigEntry -) -> bool: - """Migrate old entry.""" - entity_registry = er.async_get(hass) - - @callback - def update_unique_id(entry: er.RegistryEntry) -> dict[str, str] | None: - replacements = { - Platform.SENSOR.value: { - "charging_level_hv": "fuel_and_battery.remaining_battery_percent", - "fuel_percent": "fuel_and_battery.remaining_fuel_percent", - "ac_current_limit": "charging_profile.ac_current_limit", - "charging_start_time": "fuel_and_battery.charging_start_time", - "charging_end_time": "fuel_and_battery.charging_end_time", - "charging_status": "fuel_and_battery.charging_status", - "charging_target": "fuel_and_battery.charging_target", - "remaining_battery_percent": "fuel_and_battery.remaining_battery_percent", - "remaining_range_total": "fuel_and_battery.remaining_range_total", - "remaining_range_electric": "fuel_and_battery.remaining_range_electric", - "remaining_range_fuel": "fuel_and_battery.remaining_range_fuel", - "remaining_fuel": "fuel_and_battery.remaining_fuel", - "remaining_fuel_percent": "fuel_and_battery.remaining_fuel_percent", - "activity": "climate.activity", - } - } - if (key := entry.unique_id.split("-")[-1]) in replacements.get( - entry.domain, [] - ): - new_unique_id = entry.unique_id.replace( - key, replacements[entry.domain][key] - ) - _LOGGER.debug( - "Migrating entity '%s' unique_id from '%s' to '%s'", - entry.entity_id, - entry.unique_id, - new_unique_id, - ) - if existing_entity_id := entity_registry.async_get_entity_id( - entry.domain, entry.platform, new_unique_id - ): - _LOGGER.debug( - "Cannot migrate to unique_id '%s', already exists for '%s'", - new_unique_id, - existing_entity_id, - ) - return None - return { - "new_unique_id": new_unique_id, - } - return None - - await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) - - return True - - -async def async_setup_entry(hass: HomeAssistant, entry: BMWConfigEntry) -> bool: - """Set up BMW Connected Drive from a config entry.""" - - _async_migrate_options_from_data_if_missing(hass, entry) - - await _async_migrate_entries(hass, entry) - - # Set up one data coordinator per account/config entry - coordinator = BMWDataUpdateCoordinator( - hass, - config_entry=entry, - ) - await coordinator.async_config_entry_first_refresh() - - entry.runtime_data = coordinator - - # Set up all platforms except notify - await hass.config_entries.async_forward_entry_setups( - entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] - ) - - # set up notify platform, no entry support for notify platform yet, - # have to use discovery to load platform. - hass.async_create_task( - discovery.async_load_platform( - hass, - Platform.NOTIFY, - DOMAIN, - {CONF_NAME: DOMAIN, CONF_ENTITY_ID: entry.entry_id}, - {}, - ) - ) - - # Clean up vehicles which are not assigned to the account anymore - account_vehicles = {(DOMAIN, v.vin) for v in coordinator.account.vehicles} - device_registry = dr.async_get(hass) - device_entries = dr.async_entries_for_config_entry( - device_registry, config_entry_id=entry.entry_id - ) - for device in device_entries: - if not device.identifiers.intersection(account_vehicles): - device_registry.async_update_device( - device.id, remove_config_entry_id=entry.entry_id - ) - - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: BMWConfigEntry) -> bool: - """Unload a config entry.""" - - return await hass.config_entries.async_unload_platforms( - entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] - ) diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py deleted file mode 100644 index b96450c3b5acd1..00000000000000 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ /dev/null @@ -1,254 +0,0 @@ -"""Reads vehicle status from BMW MyBMW portal.""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass -import logging -from typing import Any - -from bimmer_connected.vehicle import MyBMWVehicle -from bimmer_connected.vehicle.doors_windows import LockState -from bimmer_connected.vehicle.fuel_and_battery import ChargingState -from bimmer_connected.vehicle.reports import ConditionBasedService - -from homeassistant.components.binary_sensor import ( - BinarySensorDeviceClass, - BinarySensorEntity, - BinarySensorEntityDescription, -) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util.unit_system import UnitSystem - -from . import BMWConfigEntry -from .const import UNIT_MAP -from .coordinator import BMWDataUpdateCoordinator -from .entity import BMWBaseEntity - -PARALLEL_UPDATES = 0 - -_LOGGER = logging.getLogger(__name__) - - -ALLOWED_CONDITION_BASED_SERVICE_KEYS = { - "BRAKE_FLUID", - "BRAKE_PADS_FRONT", - "BRAKE_PADS_REAR", - "EMISSION_CHECK", - "ENGINE_OIL", - "OIL", - "TIRE_WEAR_FRONT", - "TIRE_WEAR_REAR", - "VEHICLE_CHECK", - "VEHICLE_TUV", -} -LOGGED_CONDITION_BASED_SERVICE_WARNINGS: set[str] = set() - -ALLOWED_CHECK_CONTROL_MESSAGE_KEYS = { - "ENGINE_OIL", - "TIRE_PRESSURE", - "WASHING_FLUID", -} -LOGGED_CHECK_CONTROL_MESSAGE_WARNINGS: set[str] = set() - - -def _condition_based_services( - vehicle: MyBMWVehicle, unit_system: UnitSystem -) -> dict[str, Any]: - extra_attributes = {} - for report in vehicle.condition_based_services.messages: - if ( - report.service_type not in ALLOWED_CONDITION_BASED_SERVICE_KEYS - and report.service_type not in LOGGED_CONDITION_BASED_SERVICE_WARNINGS - ): - _LOGGER.warning( - "'%s' not an allowed condition based service (%s)", - report.service_type, - report, - ) - LOGGED_CONDITION_BASED_SERVICE_WARNINGS.add(report.service_type) - continue - - extra_attributes.update(_format_cbs_report(report, unit_system)) - return extra_attributes - - -def _check_control_messages(vehicle: MyBMWVehicle) -> dict[str, Any]: - extra_attributes: dict[str, Any] = {} - for message in vehicle.check_control_messages.messages: - if ( - message.description_short not in ALLOWED_CHECK_CONTROL_MESSAGE_KEYS - and message.description_short not in LOGGED_CHECK_CONTROL_MESSAGE_WARNINGS - ): - _LOGGER.warning( - "'%s' not an allowed check control message (%s)", - message.description_short, - message, - ) - LOGGED_CHECK_CONTROL_MESSAGE_WARNINGS.add(message.description_short) - continue - - extra_attributes[message.description_short.lower()] = message.state.value - return extra_attributes - - -def _format_cbs_report( - report: ConditionBasedService, unit_system: UnitSystem -) -> dict[str, Any]: - result: dict[str, Any] = {} - service_type = report.service_type.lower() - result[service_type] = report.state.value - if report.due_date is not None: - result[f"{service_type}_date"] = report.due_date.strftime("%Y-%m-%d") - if report.due_distance.value and report.due_distance.unit: - distance = round( - unit_system.length( - report.due_distance.value, - UNIT_MAP.get(report.due_distance.unit, report.due_distance.unit), - ) - ) - result[f"{service_type}_distance"] = f"{distance} {unit_system.length_unit}" - return result - - -@dataclass(frozen=True, kw_only=True) -class BMWBinarySensorEntityDescription(BinarySensorEntityDescription): - """Describes BMW binary_sensor entity.""" - - value_fn: Callable[[MyBMWVehicle], bool] - attr_fn: Callable[[MyBMWVehicle, UnitSystem], dict[str, Any]] | None = None - is_available: Callable[[MyBMWVehicle], bool] = lambda v: v.is_lsc_enabled - - -SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = ( - BMWBinarySensorEntityDescription( - key="lids", - translation_key="lids", - device_class=BinarySensorDeviceClass.OPENING, - # device class opening: On means open, Off means closed - value_fn=lambda v: not v.doors_and_windows.all_lids_closed, - attr_fn=lambda v, u: { - lid.name: lid.state.value for lid in v.doors_and_windows.lids - }, - ), - BMWBinarySensorEntityDescription( - key="windows", - translation_key="windows", - device_class=BinarySensorDeviceClass.OPENING, - # device class opening: On means open, Off means closed - value_fn=lambda v: not v.doors_and_windows.all_windows_closed, - attr_fn=lambda v, u: { - window.name: window.state.value for window in v.doors_and_windows.windows - }, - ), - BMWBinarySensorEntityDescription( - key="door_lock_state", - translation_key="door_lock_state", - device_class=BinarySensorDeviceClass.LOCK, - # device class lock: On means unlocked, Off means locked - # Possible values: LOCKED, SECURED, SELECTIVE_LOCKED, UNLOCKED - value_fn=lambda v: ( - v.doors_and_windows.door_lock_state - not in {LockState.LOCKED, LockState.SECURED} - ), - attr_fn=lambda v, u: { - "door_lock_state": v.doors_and_windows.door_lock_state.value - }, - ), - BMWBinarySensorEntityDescription( - key="condition_based_services", - translation_key="condition_based_services", - device_class=BinarySensorDeviceClass.PROBLEM, - # device class problem: On means problem detected, Off means no problem - value_fn=lambda v: v.condition_based_services.is_service_required, - attr_fn=_condition_based_services, - ), - BMWBinarySensorEntityDescription( - key="check_control_messages", - translation_key="check_control_messages", - device_class=BinarySensorDeviceClass.PROBLEM, - # device class problem: On means problem detected, Off means no problem - value_fn=lambda v: v.check_control_messages.has_check_control_messages, - attr_fn=lambda v, u: _check_control_messages(v), - ), - # electric - BMWBinarySensorEntityDescription( - key="charging_status", - translation_key="charging_status", - device_class=BinarySensorDeviceClass.BATTERY_CHARGING, - # device class power: On means power detected, Off means no power - value_fn=lambda v: v.fuel_and_battery.charging_status == ChargingState.CHARGING, - is_available=lambda v: v.has_electric_drivetrain, - ), - BMWBinarySensorEntityDescription( - key="connection_status", - translation_key="connection_status", - device_class=BinarySensorDeviceClass.PLUG, - value_fn=lambda v: v.fuel_and_battery.is_charger_connected, - is_available=lambda v: v.has_electric_drivetrain, - ), - BMWBinarySensorEntityDescription( - key="is_pre_entry_climatization_enabled", - translation_key="is_pre_entry_climatization_enabled", - value_fn=lambda v: ( - v.charging_profile.is_pre_entry_climatization_enabled - if v.charging_profile - else False - ), - is_available=lambda v: v.has_electric_drivetrain, - ), -) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: BMWConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up the BMW binary sensors from config entry.""" - coordinator = config_entry.runtime_data - - entities = [ - BMWBinarySensor(coordinator, vehicle, description, hass.config.units) - for vehicle in coordinator.account.vehicles - for description in SENSOR_TYPES - if description.is_available(vehicle) - ] - async_add_entities(entities) - - -class BMWBinarySensor(BMWBaseEntity, BinarySensorEntity): - """Representation of a BMW vehicle binary sensor.""" - - entity_description: BMWBinarySensorEntityDescription - - def __init__( - self, - coordinator: BMWDataUpdateCoordinator, - vehicle: MyBMWVehicle, - description: BMWBinarySensorEntityDescription, - unit_system: UnitSystem, - ) -> None: - """Initialize sensor.""" - super().__init__(coordinator, vehicle) - self.entity_description = description - self._unit_system = unit_system - self._attr_unique_id = f"{vehicle.vin}-{description.key}" - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - _LOGGER.debug( - "Updating binary sensor '%s' of %s", - self.entity_description.key, - self.vehicle.name, - ) - self._attr_is_on = self.entity_description.value_fn(self.vehicle) - - if self.entity_description.attr_fn: - self._attr_extra_state_attributes = self.entity_description.attr_fn( - self.vehicle, self._unit_system - ) - - super()._handle_coordinator_update() diff --git a/homeassistant/components/bmw_connected_drive/button.py b/homeassistant/components/bmw_connected_drive/button.py deleted file mode 100644 index 250b54100ddfa9..00000000000000 --- a/homeassistant/components/bmw_connected_drive/button.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Support for MyBMW button entities.""" - -from __future__ import annotations - -from collections.abc import Callable, Coroutine -from dataclasses import dataclass -import logging -from typing import TYPE_CHECKING, Any - -from bimmer_connected.models import MyBMWAPIError -from bimmer_connected.vehicle import MyBMWVehicle -from bimmer_connected.vehicle.remote_services import RemoteServiceStatus - -from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from . import DOMAIN, BMWConfigEntry -from .entity import BMWBaseEntity - -if TYPE_CHECKING: - from .coordinator import BMWDataUpdateCoordinator - -PARALLEL_UPDATES = 1 - -_LOGGER = logging.getLogger(__name__) - - -@dataclass(frozen=True, kw_only=True) -class BMWButtonEntityDescription(ButtonEntityDescription): - """Class describing BMW button entities.""" - - remote_function: Callable[[MyBMWVehicle], Coroutine[Any, Any, RemoteServiceStatus]] - enabled_when_read_only: bool = False - is_available: Callable[[MyBMWVehicle], bool] = lambda _: True - - -BUTTON_TYPES: tuple[BMWButtonEntityDescription, ...] = ( - BMWButtonEntityDescription( - key="light_flash", - translation_key="light_flash", - remote_function=lambda vehicle: ( - vehicle.remote_services.trigger_remote_light_flash() - ), - ), - BMWButtonEntityDescription( - key="sound_horn", - translation_key="sound_horn", - remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_horn(), - ), - BMWButtonEntityDescription( - key="activate_air_conditioning", - translation_key="activate_air_conditioning", - remote_function=lambda vehicle: ( - vehicle.remote_services.trigger_remote_air_conditioning() - ), - ), - BMWButtonEntityDescription( - key="deactivate_air_conditioning", - translation_key="deactivate_air_conditioning", - remote_function=lambda vehicle: ( - vehicle.remote_services.trigger_remote_air_conditioning_stop() - ), - is_available=lambda vehicle: vehicle.is_remote_climate_stop_enabled, - ), - BMWButtonEntityDescription( - key="find_vehicle", - translation_key="find_vehicle", - remote_function=lambda vehicle: ( - vehicle.remote_services.trigger_remote_vehicle_finder() - ), - ), -) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: BMWConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up the BMW buttons from config entry.""" - coordinator = config_entry.runtime_data - - entities: list[BMWButton] = [] - - for vehicle in coordinator.account.vehicles: - entities.extend( - [ - BMWButton(coordinator, vehicle, description) - for description in BUTTON_TYPES - if (not coordinator.read_only and description.is_available(vehicle)) - or (coordinator.read_only and description.enabled_when_read_only) - ] - ) - - async_add_entities(entities) - - -class BMWButton(BMWBaseEntity, ButtonEntity): - """Representation of a MyBMW button.""" - - entity_description: BMWButtonEntityDescription - - def __init__( - self, - coordinator: BMWDataUpdateCoordinator, - vehicle: MyBMWVehicle, - description: BMWButtonEntityDescription, - ) -> None: - """Initialize BMW vehicle sensor.""" - super().__init__(coordinator, vehicle) - self.entity_description = description - self._attr_unique_id = f"{vehicle.vin}-{description.key}" - - async def async_press(self) -> None: - """Press the button.""" - try: - await self.entity_description.remote_function(self.vehicle) - except MyBMWAPIError as ex: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="remote_service_error", - translation_placeholders={"exception": str(ex)}, - ) from ex - - self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py deleted file mode 100644 index 5a067d234745df..00000000000000 --- a/homeassistant/components/bmw_connected_drive/config_flow.py +++ /dev/null @@ -1,277 +0,0 @@ -"""Config flow for BMW ConnectedDrive integration.""" - -from __future__ import annotations - -from collections.abc import Mapping -from typing import Any - -from bimmer_connected.api.authentication import MyBMWAuthentication -from bimmer_connected.api.regions import get_region_from_name -from bimmer_connected.models import ( - MyBMWAPIError, - MyBMWAuthError, - MyBMWCaptchaMissingError, -) -from httpx import RequestError -import voluptuous as vol - -from homeassistant.config_entries import ( - SOURCE_REAUTH, - SOURCE_RECONFIGURE, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) -from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_SOURCE, CONF_USERNAME -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig -from homeassistant.util.ssl import get_default_context - -from . import DOMAIN -from .const import ( - CONF_ALLOWED_REGIONS, - CONF_CAPTCHA_REGIONS, - CONF_CAPTCHA_TOKEN, - CONF_CAPTCHA_URL, - CONF_GCID, - CONF_READ_ONLY, - CONF_REFRESH_TOKEN, -) -from .coordinator import BMWConfigEntry - -DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_REGION): SelectSelector( - SelectSelectorConfig( - options=CONF_ALLOWED_REGIONS, - translation_key="regions", - ) - ), - }, - extra=vol.REMOVE_EXTRA, -) -RECONFIGURE_SCHEMA = vol.Schema( - { - vol.Required(CONF_PASSWORD): str, - }, - extra=vol.REMOVE_EXTRA, -) -CAPTCHA_SCHEMA = vol.Schema( - { - vol.Required(CONF_CAPTCHA_TOKEN): str, - }, - extra=vol.REMOVE_EXTRA, -) - - -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]: - """Validate the user input allows us to connect. - - Data has the keys from DATA_SCHEMA with values provided by the user. - """ - auth = MyBMWAuthentication( - data[CONF_USERNAME], - data[CONF_PASSWORD], - get_region_from_name(data[CONF_REGION]), - hcaptcha_token=data.get(CONF_CAPTCHA_TOKEN), - verify=get_default_context(), - ) - - try: - await auth.login() - except MyBMWCaptchaMissingError as ex: - raise MissingCaptcha from ex - except MyBMWAuthError as ex: - raise InvalidAuth from ex - except (MyBMWAPIError, RequestError) as ex: - raise CannotConnect from ex - - # Return info that you want to store in the config entry. - retval = {"title": f"{data[CONF_USERNAME]}{data.get(CONF_SOURCE, '')}"} - if auth.refresh_token: - retval[CONF_REFRESH_TOKEN] = auth.refresh_token - if auth.gcid: - retval[CONF_GCID] = auth.gcid - return retval - - -class BMWConfigFlow(ConfigFlow, domain=DOMAIN): - """Handle a config flow for MyBMW.""" - - VERSION = 1 - - def __init__(self) -> None: - """Initialize the config flow.""" - self.data: dict[str, Any] = {} - self._existing_entry_data: dict[str, Any] = {} - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle the initial step.""" - errors: dict[str, str] = self.data.pop("errors", {}) - - if user_input is not None and not errors: - unique_id = f"{user_input[CONF_REGION]}-{user_input[CONF_USERNAME]}" - await self.async_set_unique_id(unique_id) - - # Unique ID cannot change for reauth/reconfigure - if self.source not in {SOURCE_REAUTH, SOURCE_RECONFIGURE}: - self._abort_if_unique_id_configured() - - # Store user input for later use - self.data.update(user_input) - - # North America and Rest of World require captcha token - if ( - self.data.get(CONF_REGION) in CONF_CAPTCHA_REGIONS - and CONF_CAPTCHA_TOKEN not in self.data - ): - return await self.async_step_captcha() - - info = None - try: - info = await validate_input(self.hass, self.data) - except MissingCaptcha: - errors["base"] = "missing_captcha" - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: - errors["base"] = "invalid_auth" - finally: - self.data.pop(CONF_CAPTCHA_TOKEN, None) - - if info: - entry_data = { - **self.data, - CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN), - CONF_GCID: info.get(CONF_GCID), - } - - if self.source == SOURCE_REAUTH: - return self.async_update_reload_and_abort( - self._get_reauth_entry(), data=entry_data - ) - if self.source == SOURCE_RECONFIGURE: - return self.async_update_reload_and_abort( - self._get_reconfigure_entry(), - data=entry_data, - ) - return self.async_create_entry( - title=info["title"], - data=entry_data, - ) - - schema = self.add_suggested_values_to_schema( - DATA_SCHEMA, - self._existing_entry_data or self.data, - ) - - return self.async_show_form(step_id="user", data_schema=schema, errors=errors) - - async def async_step_change_password( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Show the change password step.""" - if user_input is not None: - return await self.async_step_user(self._existing_entry_data | user_input) - - return self.async_show_form( - step_id="change_password", - data_schema=RECONFIGURE_SCHEMA, - description_placeholders={ - CONF_USERNAME: self._existing_entry_data[CONF_USERNAME], - CONF_REGION: self._existing_entry_data[CONF_REGION], - }, - ) - - async def async_step_reauth( - self, entry_data: Mapping[str, Any] - ) -> ConfigFlowResult: - """Handle configuration by re-auth.""" - self._existing_entry_data = dict(entry_data) - return await self.async_step_change_password() - - async def async_step_reconfigure( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle a reconfiguration flow initialized by the user.""" - self._existing_entry_data = dict(self._get_reconfigure_entry().data) - return await self.async_step_change_password() - - async def async_step_captcha( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Show captcha form.""" - if user_input and user_input.get(CONF_CAPTCHA_TOKEN): - self.data[CONF_CAPTCHA_TOKEN] = user_input[CONF_CAPTCHA_TOKEN].strip() - return await self.async_step_user(self.data) - - return self.async_show_form( - step_id="captcha", - data_schema=CAPTCHA_SCHEMA, - description_placeholders={ - "captcha_url": CONF_CAPTCHA_URL.format(region=self.data[CONF_REGION]) - }, - ) - - @staticmethod - @callback - def async_get_options_flow( - config_entry: BMWConfigEntry, - ) -> BMWOptionsFlow: - """Return a MyBMW option flow.""" - return BMWOptionsFlow() - - -class BMWOptionsFlow(OptionsFlow): - """Handle a option flow for MyBMW.""" - - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Manage the options.""" - return await self.async_step_account_options() - - async def async_step_account_options( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle the initial step.""" - if user_input is not None: - # Manually update & reload the config entry after options change. - # Required as each successful login will store the latest refresh_token - # using async_update_entry, which would otherwise trigger a full reload - # if the options would be refreshed using a listener. - changed = self.hass.config_entries.async_update_entry( - self.config_entry, - options=user_input, - ) - if changed: - await self.hass.config_entries.async_reload(self.config_entry.entry_id) - return self.async_create_entry(title="", data=user_input) - return self.async_show_form( - step_id="account_options", - data_schema=vol.Schema( - { - vol.Optional( - CONF_READ_ONLY, - default=self.config_entry.options.get(CONF_READ_ONLY, False), - ): bool, - } - ), - ) - - -class CannotConnect(HomeAssistantError): - """Error to indicate we cannot connect.""" - - -class InvalidAuth(HomeAssistantError): - """Error to indicate there is invalid auth.""" - - -class MissingCaptcha(HomeAssistantError): - """Error to indicate the captcha token is missing.""" diff --git a/homeassistant/components/bmw_connected_drive/const.py b/homeassistant/components/bmw_connected_drive/const.py deleted file mode 100644 index 750289e9d0a0d7..00000000000000 --- a/homeassistant/components/bmw_connected_drive/const.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Const file for the MyBMW integration.""" - -from homeassistant.const import UnitOfLength, UnitOfVolume - -DOMAIN = "bmw_connected_drive" - -ATTR_DIRECTION = "direction" -ATTR_VIN = "vin" - -CONF_ALLOWED_REGIONS = ["china", "north_america", "rest_of_world"] -CONF_CAPTCHA_REGIONS = ["north_america", "rest_of_world"] -CONF_READ_ONLY = "read_only" -CONF_ACCOUNT = "account" -CONF_REFRESH_TOKEN = "refresh_token" -CONF_GCID = "gcid" -CONF_CAPTCHA_TOKEN = "captcha_token" -CONF_CAPTCHA_URL = ( - "https://bimmer-connected.readthedocs.io/en/stable/captcha/{region}.html" -) - -DATA_HASS_CONFIG = "hass_config" - -UNIT_MAP = { - "KILOMETERS": UnitOfLength.KILOMETERS, - "MILES": UnitOfLength.MILES, - "LITERS": UnitOfVolume.LITERS, - "GALLONS": UnitOfVolume.GALLONS, -} - -SCAN_INTERVALS = { - "china": 300, - "north_america": 600, - "rest_of_world": 300, -} diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py deleted file mode 100644 index 73e19ca7af5573..00000000000000 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Coordinator for BMW.""" - -from __future__ import annotations - -from datetime import timedelta -import logging - -from bimmer_connected.account import MyBMWAccount -from bimmer_connected.api.regions import get_region_from_name -from bimmer_connected.models import ( - GPSPosition, - MyBMWAPIError, - MyBMWAuthError, - MyBMWCaptchaMissingError, -) -from httpx import RequestError - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util.ssl import get_default_context - -from .const import CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN, SCAN_INTERVALS - -_LOGGER = logging.getLogger(__name__) - - -type BMWConfigEntry = ConfigEntry[BMWDataUpdateCoordinator] - - -class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): - """Class to manage fetching BMW data.""" - - account: MyBMWAccount - config_entry: BMWConfigEntry - - def __init__(self, hass: HomeAssistant, *, config_entry: BMWConfigEntry) -> None: - """Initialize account-wide BMW data updater.""" - self.account = MyBMWAccount( - config_entry.data[CONF_USERNAME], - config_entry.data[CONF_PASSWORD], - get_region_from_name(config_entry.data[CONF_REGION]), - observer_position=GPSPosition(hass.config.latitude, hass.config.longitude), - verify=get_default_context(), - ) - self.read_only: bool = config_entry.options[CONF_READ_ONLY] - - if CONF_REFRESH_TOKEN in config_entry.data: - self.account.set_refresh_token( - refresh_token=config_entry.data[CONF_REFRESH_TOKEN], - gcid=config_entry.data.get(CONF_GCID), - ) - - super().__init__( - hass, - _LOGGER, - config_entry=config_entry, - name=f"{DOMAIN}-{config_entry.data[CONF_USERNAME]}", - update_interval=timedelta( - seconds=SCAN_INTERVALS[config_entry.data[CONF_REGION]] - ), - ) - - # Default to false on init so _async_update_data logic works - self.last_update_success = False - - async def _async_update_data(self) -> None: - """Fetch data from BMW.""" - old_refresh_token = self.account.refresh_token - - try: - await self.account.get_vehicles() - except MyBMWCaptchaMissingError as err: - # If a captcha is required (user/password login flow), always trigger the reauth flow - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="missing_captcha", - ) from err - except MyBMWAuthError as err: - # Allow one retry interval before raising AuthFailed to avoid flaky API issues - if self.last_update_success: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="update_failed", - translation_placeholders={"exception": str(err)}, - ) from err - # Clear refresh token and trigger reauth if previous update failed as well - self._update_config_entry_refresh_token(None) - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="invalid_auth", - ) from err - except (MyBMWAPIError, RequestError) as err: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="update_failed", - translation_placeholders={"exception": str(err)}, - ) from err - - if self.account.refresh_token != old_refresh_token: - self._update_config_entry_refresh_token(self.account.refresh_token) - - def _update_config_entry_refresh_token(self, refresh_token: str | None) -> None: - """Update or delete the refresh_token in the Config Entry.""" - data = { - **self.config_entry.data, - CONF_REFRESH_TOKEN: refresh_token, - } - if not refresh_token: - data.pop(CONF_REFRESH_TOKEN) - self.hass.config_entries.async_update_entry(self.config_entry, data=data) diff --git a/homeassistant/components/bmw_connected_drive/device_tracker.py b/homeassistant/components/bmw_connected_drive/device_tracker.py deleted file mode 100644 index 23273cc8ba985d..00000000000000 --- a/homeassistant/components/bmw_connected_drive/device_tracker.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Device tracker for MyBMW vehicles.""" - -from __future__ import annotations - -import logging -from typing import Any - -from bimmer_connected.vehicle import MyBMWVehicle - -from homeassistant.components.device_tracker import TrackerEntity -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from . import BMWConfigEntry -from .const import ATTR_DIRECTION -from .coordinator import BMWDataUpdateCoordinator -from .entity import BMWBaseEntity - -PARALLEL_UPDATES = 0 - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: BMWConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up the MyBMW tracker from config entry.""" - coordinator = config_entry.runtime_data - entities: list[BMWDeviceTracker] = [] - - for vehicle in coordinator.account.vehicles: - entities.append(BMWDeviceTracker(coordinator, vehicle)) - if not vehicle.is_vehicle_tracking_enabled: - _LOGGER.info( - ( - "Tracking is (currently) disabled for vehicle %s (%s), defaulting" - " to unknown" - ), - vehicle.name, - vehicle.vin, - ) - async_add_entities(entities) - - -class BMWDeviceTracker(BMWBaseEntity, TrackerEntity): - """MyBMW device tracker.""" - - _attr_force_update = False - _attr_translation_key = "car" - _attr_name = None - - def __init__( - self, - coordinator: BMWDataUpdateCoordinator, - vehicle: MyBMWVehicle, - ) -> None: - """Initialize the Tracker.""" - super().__init__(coordinator, vehicle) - self._attr_unique_id = vehicle.vin - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return entity specific state attributes.""" - return {ATTR_DIRECTION: self.vehicle.vehicle_location.heading} - - @property - def latitude(self) -> float | None: - """Return latitude value of the device.""" - return ( - self.vehicle.vehicle_location.location[0] - if self.vehicle.is_vehicle_tracking_enabled - and self.vehicle.vehicle_location.location - else None - ) - - @property - def longitude(self) -> float | None: - """Return longitude value of the device.""" - return ( - self.vehicle.vehicle_location.location[1] - if self.vehicle.is_vehicle_tracking_enabled - and self.vehicle.vehicle_location.location - else None - ) diff --git a/homeassistant/components/bmw_connected_drive/diagnostics.py b/homeassistant/components/bmw_connected_drive/diagnostics.py deleted file mode 100644 index 3f357c3ae79d22..00000000000000 --- a/homeassistant/components/bmw_connected_drive/diagnostics.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Diagnostics support for the BMW Connected Drive integration.""" - -from __future__ import annotations - -from dataclasses import asdict -import json -from typing import TYPE_CHECKING, Any - -from bimmer_connected.utils import MyBMWJSONEncoder - -from homeassistant.components.diagnostics import async_redact_data -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntry - -from . import BMWConfigEntry -from .const import CONF_REFRESH_TOKEN - -PARALLEL_UPDATES = 1 - -if TYPE_CHECKING: - from bimmer_connected.vehicle import MyBMWVehicle - - -TO_REDACT_INFO = [CONF_USERNAME, CONF_PASSWORD, CONF_REFRESH_TOKEN] -TO_REDACT_DATA = [ - "lat", - "latitude", - "lon", - "longitude", - "heading", - "vin", - "licensePlate", - "city", - "street", - "streetNumber", - "postalCode", - "phone", - "formatted", - "subtitle", -] - - -def vehicle_to_dict(vehicle: MyBMWVehicle | None) -> dict: - """Convert a MyBMWVehicle to a dictionary using MyBMWJSONEncoder.""" - retval: dict = json.loads(json.dumps(vehicle, cls=MyBMWJSONEncoder)) - return retval - - -async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: BMWConfigEntry -) -> dict[str, Any]: - """Return diagnostics for a config entry.""" - coordinator = config_entry.runtime_data - - coordinator.account.config.log_responses = True - await coordinator.account.get_vehicles(force_init=True) - - diagnostics_data = { - "info": async_redact_data(config_entry.data, TO_REDACT_INFO), - "data": [ - async_redact_data(vehicle_to_dict(vehicle), TO_REDACT_DATA) - for vehicle in coordinator.account.vehicles - ], - "fingerprint": async_redact_data( - [asdict(r) for r in coordinator.account.get_stored_responses()], - TO_REDACT_DATA, - ), - } - - coordinator.account.config.log_responses = False - - return diagnostics_data - - -async def async_get_device_diagnostics( - hass: HomeAssistant, config_entry: BMWConfigEntry, device: DeviceEntry -) -> dict[str, Any]: - """Return diagnostics for a device.""" - coordinator = config_entry.runtime_data - - coordinator.account.config.log_responses = True - await coordinator.account.get_vehicles(force_init=True) - - vin = next(iter(device.identifiers))[1] - vehicle = coordinator.account.get_vehicle(vin) - - diagnostics_data = { - "info": async_redact_data(config_entry.data, TO_REDACT_INFO), - "data": async_redact_data(vehicle_to_dict(vehicle), TO_REDACT_DATA), - # Always have to get the full fingerprint as the VIN is redacted beforehand by the library - "fingerprint": async_redact_data( - [asdict(r) for r in coordinator.account.get_stored_responses()], - TO_REDACT_DATA, - ), - } - - coordinator.account.config.log_responses = False - - return diagnostics_data diff --git a/homeassistant/components/bmw_connected_drive/entity.py b/homeassistant/components/bmw_connected_drive/entity.py deleted file mode 100644 index 806312170ebb80..00000000000000 --- a/homeassistant/components/bmw_connected_drive/entity.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Base for all BMW entities.""" - -from __future__ import annotations - -from bimmer_connected.vehicle import MyBMWVehicle - -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import DOMAIN -from .coordinator import BMWDataUpdateCoordinator - - -class BMWBaseEntity(CoordinatorEntity[BMWDataUpdateCoordinator]): - """Common base for BMW entities.""" - - _attr_has_entity_name = True - - def __init__( - self, - coordinator: BMWDataUpdateCoordinator, - vehicle: MyBMWVehicle, - ) -> None: - """Initialize entity.""" - super().__init__(coordinator) - - self.vehicle = vehicle - - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, vehicle.vin)}, - manufacturer=vehicle.brand.name, - model=vehicle.name, - name=vehicle.name, - serial_number=vehicle.vin, - ) - - async def async_added_to_hass(self) -> None: - """When entity is added to hass.""" - await super().async_added_to_hass() - self._handle_coordinator_update() diff --git a/homeassistant/components/bmw_connected_drive/icons.json b/homeassistant/components/bmw_connected_drive/icons.json deleted file mode 100644 index 8d3c1e03294eed..00000000000000 --- a/homeassistant/components/bmw_connected_drive/icons.json +++ /dev/null @@ -1,102 +0,0 @@ -{ - "entity": { - "binary_sensor": { - "charging_status": { - "default": "mdi:ev-station" - }, - "check_control_messages": { - "default": "mdi:car-tire-alert" - }, - "condition_based_services": { - "default": "mdi:wrench" - }, - "connection_status": { - "default": "mdi:car-electric" - }, - "door_lock_state": { - "default": "mdi:car-key" - }, - "is_pre_entry_climatization_enabled": { - "default": "mdi:car-seat-heater" - }, - "lids": { - "default": "mdi:car-door-lock" - }, - "windows": { - "default": "mdi:car-door" - } - }, - "button": { - "activate_air_conditioning": { - "default": "mdi:hvac" - }, - "deactivate_air_conditioning": { - "default": "mdi:hvac-off" - }, - "find_vehicle": { - "default": "mdi:crosshairs-question" - }, - "light_flash": { - "default": "mdi:car-light-alert" - }, - "sound_horn": { - "default": "mdi:bullhorn" - } - }, - "device_tracker": { - "car": { - "default": "mdi:car" - } - }, - "number": { - "target_soc": { - "default": "mdi:battery-charging-medium" - } - }, - "select": { - "ac_limit": { - "default": "mdi:current-ac" - }, - "charging_mode": { - "default": "mdi:vector-point-select" - } - }, - "sensor": { - "charging_status": { - "default": "mdi:ev-station" - }, - "charging_target": { - "default": "mdi:battery-charging-high" - }, - "climate_status": { - "default": "mdi:fan" - }, - "mileage": { - "default": "mdi:speedometer" - }, - "remaining_fuel": { - "default": "mdi:gas-station" - }, - "remaining_fuel_percent": { - "default": "mdi:gas-station" - }, - "remaining_range_electric": { - "default": "mdi:map-marker-distance" - }, - "remaining_range_fuel": { - "default": "mdi:map-marker-distance" - }, - "remaining_range_total": { - "default": "mdi:map-marker-distance" - } - }, - "switch": { - "charging": { - "default": "mdi:ev-station" - }, - "climate": { - "default": "mdi:fan" - } - } - } -} diff --git a/homeassistant/components/bmw_connected_drive/lock.py b/homeassistant/components/bmw_connected_drive/lock.py deleted file mode 100644 index 149647a3397695..00000000000000 --- a/homeassistant/components/bmw_connected_drive/lock.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Support for BMW car locks with BMW ConnectedDrive.""" - -from __future__ import annotations - -import logging -from typing import Any - -from bimmer_connected.models import MyBMWAPIError -from bimmer_connected.vehicle import MyBMWVehicle -from bimmer_connected.vehicle.doors_windows import LockState - -from homeassistant.components.lock import LockEntity -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from . import DOMAIN, BMWConfigEntry -from .coordinator import BMWDataUpdateCoordinator -from .entity import BMWBaseEntity - -PARALLEL_UPDATES = 1 - -DOOR_LOCK_STATE = "door_lock_state" - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: BMWConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up the MyBMW lock from config entry.""" - coordinator = config_entry.runtime_data - - if not coordinator.read_only: - async_add_entities( - BMWLock(coordinator, vehicle) for vehicle in coordinator.account.vehicles - ) - - -class BMWLock(BMWBaseEntity, LockEntity): - """Representation of a MyBMW vehicle lock.""" - - _attr_translation_key = "lock" - - def __init__( - self, - coordinator: BMWDataUpdateCoordinator, - vehicle: MyBMWVehicle, - ) -> None: - """Initialize the lock.""" - super().__init__(coordinator, vehicle) - - self._attr_unique_id = f"{vehicle.vin}-lock" - self.door_lock_state_available = vehicle.is_lsc_enabled - - async def async_lock(self, **kwargs: Any) -> None: - """Lock the car.""" - _LOGGER.debug("%s: locking doors", self.vehicle.name) - # Only update the HA state machine if the vehicle reliably reports its lock state - if self.door_lock_state_available: - # Optimistic state set here because it takes some time before the - # update callback response - self._attr_is_locked = True - self.async_write_ha_state() - try: - await self.vehicle.remote_services.trigger_remote_door_lock() - except MyBMWAPIError as ex: - # Set the state to unknown if the command fails - self._attr_is_locked = None - self.async_write_ha_state() - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="remote_service_error", - translation_placeholders={"exception": str(ex)}, - ) from ex - finally: - # Always update the listeners to get the latest state - self.coordinator.async_update_listeners() - - async def async_unlock(self, **kwargs: Any) -> None: - """Unlock the car.""" - _LOGGER.debug("%s: unlocking doors", self.vehicle.name) - # Only update the HA state machine if the vehicle reliably reports its lock state - if self.door_lock_state_available: - # Optimistic state set here because it takes some time before the - # update callback response - self._attr_is_locked = False - self.async_write_ha_state() - try: - await self.vehicle.remote_services.trigger_remote_door_unlock() - except MyBMWAPIError as ex: - # Set the state to unknown if the command fails - self._attr_is_locked = None - self.async_write_ha_state() - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="remote_service_error", - translation_placeholders={"exception": str(ex)}, - ) from ex - finally: - # Always update the listeners to get the latest state - self.coordinator.async_update_listeners() - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - _LOGGER.debug("Updating lock data of %s", self.vehicle.name) - - # Only update the HA state machine if the vehicle reliably reports its lock state - if self.door_lock_state_available: - self._attr_is_locked = self.vehicle.doors_and_windows.door_lock_state in { - LockState.LOCKED, - LockState.SECURED, - } - self._attr_extra_state_attributes = { - DOOR_LOCK_STATE: self.vehicle.doors_and_windows.door_lock_state.value - } - - super()._handle_coordinator_update() diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json deleted file mode 100644 index e23c710b86907b..00000000000000 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "domain": "bmw_connected_drive", - "name": "BMW Connected Drive", - "codeowners": ["@gerard33", "@rikroe"], - "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", - "integration_type": "hub", - "iot_class": "cloud_polling", - "loggers": ["bimmer_connected"], - "requirements": ["bimmer-connected[china]==0.17.3"] -} diff --git a/homeassistant/components/bmw_connected_drive/notify.py b/homeassistant/components/bmw_connected_drive/notify.py deleted file mode 100644 index 2a94cf42853046..00000000000000 --- a/homeassistant/components/bmw_connected_drive/notify.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Support for BMW notifications.""" - -from __future__ import annotations - -import logging -from typing import Any, cast - -from bimmer_connected.models import MyBMWAPIError, PointOfInterest -from bimmer_connected.vehicle import MyBMWVehicle -import voluptuous as vol - -from homeassistant.components.notify import ( - ATTR_DATA, - ATTR_TARGET, - BaseNotificationService, -) -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ENTITY_ID -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -from . import DOMAIN, BMWConfigEntry - -PARALLEL_UPDATES = 1 - -ATTR_LOCATION_ATTRIBUTES = ["street", "city", "postal_code", "country"] - -POI_SCHEMA = vol.Schema( - { - vol.Required(ATTR_LATITUDE): cv.latitude, - vol.Required(ATTR_LONGITUDE): cv.longitude, - vol.Optional("street"): cv.string, - vol.Optional("city"): cv.string, - vol.Optional("postal_code"): cv.string, - vol.Optional("country"): cv.string, - } -) - -_LOGGER = logging.getLogger(__name__) - - -def get_service( - hass: HomeAssistant, - config: ConfigType, - discovery_info: DiscoveryInfoType | None = None, -) -> BMWNotificationService: - """Get the BMW notification service.""" - config_entry: BMWConfigEntry | None = hass.config_entries.async_get_entry( - (discovery_info or {})[CONF_ENTITY_ID] - ) - - targets = {} - if ( - config_entry - and (coordinator := config_entry.runtime_data) - and not coordinator.read_only - ): - targets.update({v.name: v for v in coordinator.account.vehicles}) - return BMWNotificationService(targets) - - -class BMWNotificationService(BaseNotificationService): - """Send Notifications to BMW.""" - - vehicle_targets: dict[str, MyBMWVehicle] - - def __init__(self, targets: dict[str, MyBMWVehicle]) -> None: - """Set up the notification service.""" - self.vehicle_targets = targets - - @property - def targets(self) -> dict[str, Any] | None: - """Return a dictionary of registered targets.""" - return self.vehicle_targets - - async def async_send_message(self, message: str = "", **kwargs: Any) -> None: - """Send a message or POI to the car.""" - - try: - # Verify data schema - poi_data = kwargs.get(ATTR_DATA) or {} - POI_SCHEMA(poi_data) - - # Create the POI object - poi = PointOfInterest( - lat=poi_data.pop(ATTR_LATITUDE), - lon=poi_data.pop(ATTR_LONGITUDE), - name=(message or None), - **poi_data, - ) - - except (vol.Invalid, TypeError, ValueError) as ex: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_poi", - translation_placeholders={ - "poi_exception": str(ex), - }, - ) from ex - - for vehicle in kwargs[ATTR_TARGET]: - vehicle = cast(MyBMWVehicle, vehicle) - _LOGGER.debug("Sending message to %s", vehicle.name) - - try: - await vehicle.remote_services.trigger_send_poi(poi) - except MyBMWAPIError as ex: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="remote_service_error", - translation_placeholders={"exception": str(ex)}, - ) from ex diff --git a/homeassistant/components/bmw_connected_drive/number.py b/homeassistant/components/bmw_connected_drive/number.py deleted file mode 100644 index a30775caf601ba..00000000000000 --- a/homeassistant/components/bmw_connected_drive/number.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Number platform for BMW.""" - -from collections.abc import Callable, Coroutine -from dataclasses import dataclass -import logging -from typing import Any - -from bimmer_connected.models import MyBMWAPIError -from bimmer_connected.vehicle import MyBMWVehicle - -from homeassistant.components.number import ( - NumberDeviceClass, - NumberEntity, - NumberEntityDescription, - NumberMode, -) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from . import DOMAIN, BMWConfigEntry -from .coordinator import BMWDataUpdateCoordinator -from .entity import BMWBaseEntity - -PARALLEL_UPDATES = 1 - -_LOGGER = logging.getLogger(__name__) - - -@dataclass(frozen=True, kw_only=True) -class BMWNumberEntityDescription(NumberEntityDescription): - """Describes BMW number entity.""" - - value_fn: Callable[[MyBMWVehicle], float | int | None] - remote_service: Callable[[MyBMWVehicle, float | int], Coroutine[Any, Any, Any]] - is_available: Callable[[MyBMWVehicle], bool] = lambda _: False - dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None - - -NUMBER_TYPES: list[BMWNumberEntityDescription] = [ - BMWNumberEntityDescription( - key="target_soc", - translation_key="target_soc", - device_class=NumberDeviceClass.BATTERY, - is_available=lambda v: v.is_remote_set_target_soc_enabled, - native_max_value=100.0, - native_min_value=20.0, - native_step=5.0, - mode=NumberMode.SLIDER, - value_fn=lambda v: v.fuel_and_battery.charging_target, - remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update( - target_soc=int(o) - ), - ), -] - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: BMWConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up the MyBMW number from config entry.""" - coordinator = config_entry.runtime_data - - entities: list[BMWNumber] = [] - - for vehicle in coordinator.account.vehicles: - if not coordinator.read_only: - entities.extend( - [ - BMWNumber(coordinator, vehicle, description) - for description in NUMBER_TYPES - if description.is_available(vehicle) - ] - ) - async_add_entities(entities) - - -class BMWNumber(BMWBaseEntity, NumberEntity): - """Representation of BMW Number entity.""" - - entity_description: BMWNumberEntityDescription - - def __init__( - self, - coordinator: BMWDataUpdateCoordinator, - vehicle: MyBMWVehicle, - description: BMWNumberEntityDescription, - ) -> None: - """Initialize an BMW Number.""" - super().__init__(coordinator, vehicle) - self.entity_description = description - self._attr_unique_id = f"{vehicle.vin}-{description.key}" - - @property - def native_value(self) -> float | None: - """Return the entity value to represent the entity state.""" - return self.entity_description.value_fn(self.vehicle) - - async def async_set_native_value(self, value: float) -> None: - """Update to the vehicle.""" - _LOGGER.debug( - "Executing '%s' on vehicle '%s' to value '%s'", - self.entity_description.key, - self.vehicle.vin, - value, - ) - try: - await self.entity_description.remote_service(self.vehicle, value) - except MyBMWAPIError as ex: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="remote_service_error", - translation_placeholders={"exception": str(ex)}, - ) from ex - - self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bmw_connected_drive/quality_scale.yaml b/homeassistant/components/bmw_connected_drive/quality_scale.yaml deleted file mode 100644 index bc3bd51766275a..00000000000000 --- a/homeassistant/components/bmw_connected_drive/quality_scale.yaml +++ /dev/null @@ -1,107 +0,0 @@ -# + in comment indicates requirement for quality scale -# - in comment indicates issue to be fixed, not impacting quality scale -rules: - # Bronze - action-setup: - status: exempt - comment: | - Does not have custom services - appropriate-polling: done - brands: done - common-modules: - status: done - comment: | - - 2 states writes in async_added_to_hass() required for platforms that redefine _handle_coordinator_update() - config-flow-test-coverage: - status: todo - comment: | - - test_show_form doesn't really add anything - - Patch bimmer_connected imports with homeassistant.components.bmw_connected_drive.bimmer_connected imports - + Ensure that configs flows end in CREATE_ENTRY or ABORT - - Parameterize test_authentication_error, test_api_error and test_connection_error - + test_full_user_flow_implementation doesn't assert unique id of created entry - + test that aborts when a mocked config entry already exists - + don't test on internals (e.g. `coordinator.last_update_success`) but rather on the resulting state (change) - config-flow: done - dependency-transparency: done - docs-actions: - status: exempt - comment: | - Does not have custom services - docs-high-level-description: done - docs-installation-instructions: done - docs-removal-instructions: done - entity-event-setup: - status: exempt - comment: | - This integration doesn't have 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: | - Does not have custom services - 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: - status: done - comment: | - - Use constants in tests where possible - - # Gold - devices: done - diagnostics: done - discovery-update-info: - status: exempt - comment: This integration doesn't use discovery. - discovery: - status: exempt - comment: This integration doesn't use discovery. - 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: - status: todo - comment: > - To be discussed. - We cannot regularly get new devices/vehicles due to API quota limitations. - 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: | - Other than reauthentication, this integration doesn't have any cases where raising an issue is needed. - stale-devices: - status: todo - comment: > - To be discussed. - We cannot regularly check for stale devices/vehicles due to API quota limitations. - # Platinum - async-dependency: done - inject-websession: - status: todo - comment: > - To be discussed. - The library requires a custom client for API authentication, with custom auth lifecycle and user agents. - strict-typing: done diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py deleted file mode 100644 index 81e01b2bfad8d4..00000000000000 --- a/homeassistant/components/bmw_connected_drive/select.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Select platform for BMW.""" - -from collections.abc import Callable, Coroutine -from dataclasses import dataclass -import logging -from typing import Any - -from bimmer_connected.models import MyBMWAPIError -from bimmer_connected.vehicle import MyBMWVehicle -from bimmer_connected.vehicle.charging_profile import ChargingMode - -from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.const import UnitOfElectricCurrent -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from . import DOMAIN, BMWConfigEntry -from .coordinator import BMWDataUpdateCoordinator -from .entity import BMWBaseEntity - -PARALLEL_UPDATES = 1 - -_LOGGER = logging.getLogger(__name__) - - -@dataclass(frozen=True, kw_only=True) -class BMWSelectEntityDescription(SelectEntityDescription): - """Describes BMW sensor entity.""" - - current_option: Callable[[MyBMWVehicle], str] - remote_service: Callable[[MyBMWVehicle, str], Coroutine[Any, Any, Any]] - is_available: Callable[[MyBMWVehicle], bool] = lambda _: False - dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None - - -SELECT_TYPES: tuple[BMWSelectEntityDescription, ...] = ( - BMWSelectEntityDescription( - key="ac_limit", - translation_key="ac_limit", - is_available=lambda v: v.is_remote_set_ac_limit_enabled, - dynamic_options=lambda v: [ - str(lim) - for lim in v.charging_profile.ac_available_limits # type: ignore[union-attr] - ], - current_option=lambda v: str(v.charging_profile.ac_current_limit), # type: ignore[union-attr] - remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update( - ac_limit=int(o) - ), - unit_of_measurement=UnitOfElectricCurrent.AMPERE, - ), - BMWSelectEntityDescription( - key="charging_mode", - translation_key="charging_mode", - is_available=lambda v: v.is_charging_plan_supported, - options=[c.value.lower() for c in ChargingMode if c != ChargingMode.UNKNOWN], - current_option=lambda v: v.charging_profile.charging_mode.value.lower(), # type: ignore[union-attr] - remote_service=lambda v, o: v.remote_services.trigger_charging_profile_update( - charging_mode=ChargingMode(o) - ), - ), -) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: BMWConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up the MyBMW lock from config entry.""" - coordinator = config_entry.runtime_data - - entities: list[BMWSelect] = [] - - for vehicle in coordinator.account.vehicles: - if not coordinator.read_only: - entities.extend( - [ - BMWSelect(coordinator, vehicle, description) - for description in SELECT_TYPES - if description.is_available(vehicle) - ] - ) - async_add_entities(entities) - - -class BMWSelect(BMWBaseEntity, SelectEntity): - """Representation of BMW select entity.""" - - entity_description: BMWSelectEntityDescription - - def __init__( - self, - coordinator: BMWDataUpdateCoordinator, - vehicle: MyBMWVehicle, - description: BMWSelectEntityDescription, - ) -> None: - """Initialize an BMW select.""" - super().__init__(coordinator, vehicle) - self.entity_description = description - self._attr_unique_id = f"{vehicle.vin}-{description.key}" - if description.dynamic_options: - self._attr_options = description.dynamic_options(vehicle) - self._attr_current_option = description.current_option(vehicle) - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - _LOGGER.debug( - "Updating select '%s' of %s", self.entity_description.key, self.vehicle.name - ) - self._attr_current_option = self.entity_description.current_option(self.vehicle) - super()._handle_coordinator_update() - - async def async_select_option(self, option: str) -> None: - """Update to the vehicle.""" - _LOGGER.debug( - "Executing '%s' on vehicle '%s' to value '%s'", - self.entity_description.key, - self.vehicle.vin, - option, - ) - try: - await self.entity_description.remote_service(self.vehicle, option) - except MyBMWAPIError as ex: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="remote_service_error", - translation_placeholders={"exception": str(ex)}, - ) from ex - - self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py deleted file mode 100644 index 114412ef9f282e..00000000000000 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ /dev/null @@ -1,250 +0,0 @@ -"""Support for reading vehicle status from MyBMW portal.""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass -import datetime -import logging - -from bimmer_connected.models import StrEnum, ValueWithUnit -from bimmer_connected.vehicle import MyBMWVehicle -from bimmer_connected.vehicle.climate import ClimateActivityState -from bimmer_connected.vehicle.fuel_and_battery import ChargingState - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorEntityDescription, - SensorStateClass, -) -from homeassistant.const import ( - PERCENTAGE, - STATE_UNKNOWN, - UnitOfElectricCurrent, - UnitOfLength, - UnitOfPressure, - UnitOfVolume, -) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util import dt as dt_util - -from . import BMWConfigEntry -from .coordinator import BMWDataUpdateCoordinator -from .entity import BMWBaseEntity - -PARALLEL_UPDATES = 0 - -_LOGGER = logging.getLogger(__name__) - - -@dataclass(frozen=True) -class BMWSensorEntityDescription(SensorEntityDescription): - """Describes BMW sensor entity.""" - - key_class: str | None = None - is_available: Callable[[MyBMWVehicle], bool] = lambda v: v.is_lsc_enabled - - -TIRES = ["front_left", "front_right", "rear_left", "rear_right"] - -SENSOR_TYPES: list[BMWSensorEntityDescription] = [ - BMWSensorEntityDescription( - key="charging_profile.ac_current_limit", - translation_key="ac_current_limit", - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - entity_registry_enabled_default=False, - suggested_display_precision=0, - is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, - ), - BMWSensorEntityDescription( - key="fuel_and_battery.charging_start_time", - translation_key="charging_start_time", - device_class=SensorDeviceClass.TIMESTAMP, - entity_registry_enabled_default=False, - is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, - ), - BMWSensorEntityDescription( - key="fuel_and_battery.charging_end_time", - translation_key="charging_end_time", - device_class=SensorDeviceClass.TIMESTAMP, - is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, - ), - BMWSensorEntityDescription( - key="fuel_and_battery.charging_status", - translation_key="charging_status", - device_class=SensorDeviceClass.ENUM, - options=[s.value.lower() for s in ChargingState if s != ChargingState.UNKNOWN], - is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, - ), - BMWSensorEntityDescription( - key="fuel_and_battery.charging_target", - translation_key="charging_target", - native_unit_of_measurement=PERCENTAGE, - suggested_display_precision=0, - is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, - ), - BMWSensorEntityDescription( - key="fuel_and_battery.remaining_battery_percent", - translation_key="remaining_battery_percent", - device_class=SensorDeviceClass.BATTERY, - native_unit_of_measurement=PERCENTAGE, - state_class=SensorStateClass.MEASUREMENT, - suggested_display_precision=0, - is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, - ), - BMWSensorEntityDescription( - key="mileage", - translation_key="mileage", - device_class=SensorDeviceClass.DISTANCE, - native_unit_of_measurement=UnitOfLength.KILOMETERS, - state_class=SensorStateClass.TOTAL_INCREASING, - suggested_display_precision=0, - ), - BMWSensorEntityDescription( - key="fuel_and_battery.remaining_range_total", - translation_key="remaining_range_total", - device_class=SensorDeviceClass.DISTANCE, - native_unit_of_measurement=UnitOfLength.KILOMETERS, - state_class=SensorStateClass.MEASUREMENT, - suggested_display_precision=0, - ), - BMWSensorEntityDescription( - key="fuel_and_battery.remaining_range_electric", - translation_key="remaining_range_electric", - device_class=SensorDeviceClass.DISTANCE, - native_unit_of_measurement=UnitOfLength.KILOMETERS, - state_class=SensorStateClass.MEASUREMENT, - suggested_display_precision=0, - is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, - ), - BMWSensorEntityDescription( - key="fuel_and_battery.remaining_range_fuel", - translation_key="remaining_range_fuel", - device_class=SensorDeviceClass.DISTANCE, - native_unit_of_measurement=UnitOfLength.KILOMETERS, - state_class=SensorStateClass.MEASUREMENT, - suggested_display_precision=0, - is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain, - ), - BMWSensorEntityDescription( - key="fuel_and_battery.remaining_fuel", - translation_key="remaining_fuel", - device_class=SensorDeviceClass.VOLUME_STORAGE, - native_unit_of_measurement=UnitOfVolume.LITERS, - state_class=SensorStateClass.MEASUREMENT, - suggested_display_precision=0, - is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain, - ), - BMWSensorEntityDescription( - key="fuel_and_battery.remaining_fuel_percent", - translation_key="remaining_fuel_percent", - native_unit_of_measurement=PERCENTAGE, - state_class=SensorStateClass.MEASUREMENT, - suggested_display_precision=0, - is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain, - ), - BMWSensorEntityDescription( - key="climate.activity", - translation_key="climate_status", - device_class=SensorDeviceClass.ENUM, - options=[ - s.value.lower() - for s in ClimateActivityState - if s != ClimateActivityState.UNKNOWN - ], - is_available=lambda v: v.is_remote_climate_stop_enabled, - ), - *[ - BMWSensorEntityDescription( - key=f"tires.{tire}.current_pressure", - translation_key=f"{tire}_current_pressure", - device_class=SensorDeviceClass.PRESSURE, - native_unit_of_measurement=UnitOfPressure.KPA, - suggested_unit_of_measurement=UnitOfPressure.BAR, - state_class=SensorStateClass.MEASUREMENT, - suggested_display_precision=2, - is_available=lambda v: v.is_lsc_enabled and v.tires is not None, - ) - for tire in TIRES - ], - *[ - BMWSensorEntityDescription( - key=f"tires.{tire}.target_pressure", - translation_key=f"{tire}_target_pressure", - device_class=SensorDeviceClass.PRESSURE, - native_unit_of_measurement=UnitOfPressure.KPA, - suggested_unit_of_measurement=UnitOfPressure.BAR, - state_class=SensorStateClass.MEASUREMENT, - suggested_display_precision=2, - entity_registry_enabled_default=False, - is_available=lambda v: v.is_lsc_enabled and v.tires is not None, - ) - for tire in TIRES - ], -] - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: BMWConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up the MyBMW sensors from config entry.""" - coordinator = config_entry.runtime_data - - entities = [ - BMWSensor(coordinator, vehicle, description) - for vehicle in coordinator.account.vehicles - for description in SENSOR_TYPES - if description.is_available(vehicle) - ] - - async_add_entities(entities) - - -class BMWSensor(BMWBaseEntity, SensorEntity): - """Representation of a BMW vehicle sensor.""" - - entity_description: BMWSensorEntityDescription - - def __init__( - self, - coordinator: BMWDataUpdateCoordinator, - vehicle: MyBMWVehicle, - description: BMWSensorEntityDescription, - ) -> None: - """Initialize BMW vehicle sensor.""" - super().__init__(coordinator, vehicle) - self.entity_description = description - self._attr_unique_id = f"{vehicle.vin}-{description.key}" - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - _LOGGER.debug( - "Updating sensor '%s' of %s", self.entity_description.key, self.vehicle.name - ) - - key_path = self.entity_description.key.split(".") - state = getattr(self.vehicle, key_path.pop(0)) - - for key in key_path: - state = getattr(state, key) - - # For datetime without tzinfo, we assume it to be the same timezone as the HA instance - if isinstance(state, datetime.datetime) and state.tzinfo is None: - state = state.replace(tzinfo=dt_util.get_default_time_zone()) - # For enum types, we only want the value - elif isinstance(state, ValueWithUnit): - state = state.value - # Get lowercase values from StrEnum - elif isinstance(state, StrEnum): - state = state.value.lower() - if state == STATE_UNKNOWN: - state = None - - self._attr_native_value = state - super()._handle_coordinator_update() diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json deleted file mode 100644 index 5271d1d6d54ed2..00000000000000 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ /dev/null @@ -1,248 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "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%]", - "missing_captcha": "Captcha validation missing" - }, - "step": { - "captcha": { - "data": { - "captcha_token": "Captcha token" - }, - "data_description": { - "captcha_token": "One-time token retrieved from the captcha challenge." - }, - "description": "A captcha is required for BMW login. Visit the external website to complete the challenge and submit the form. Copy the resulting token into the field below.\n\n{captcha_url}\n\nNo data will be exposed outside of your Home Assistant instance.", - "title": "Are you a robot?" - }, - "change_password": { - "data": { - "password": "[%key:common::config_flow::data::password%]" - }, - "data_description": { - "password": "[%key:component::bmw_connected_drive::config::step::user::data_description::password%]" - }, - "description": "Update your MyBMW/MINI Connected password for account `{username}` in region `{region}`." - }, - "user": { - "data": { - "password": "[%key:common::config_flow::data::password%]", - "region": "ConnectedDrive region", - "username": "[%key:common::config_flow::data::username%]" - }, - "data_description": { - "password": "The password of your MyBMW/MINI Connected account.", - "region": "The region of your MyBMW/MINI Connected account.", - "username": "The email address of your MyBMW/MINI Connected account." - }, - "description": "Connect to your MyBMW/MINI Connected account to retrieve vehicle data." - } - } - }, - "entity": { - "binary_sensor": { - "charging_status": { - "name": "Charging status" - }, - "check_control_messages": { - "name": "Check control messages" - }, - "condition_based_services": { - "name": "Condition-based services" - }, - "connection_status": { - "name": "Connection status" - }, - "door_lock_state": { - "name": "Door lock state" - }, - "is_pre_entry_climatization_enabled": { - "name": "Pre-entry climatization" - }, - "lids": { - "name": "Lids" - }, - "windows": { - "name": "Windows" - } - }, - "button": { - "activate_air_conditioning": { - "name": "Activate air conditioning" - }, - "deactivate_air_conditioning": { - "name": "Deactivate air conditioning" - }, - "find_vehicle": { - "name": "Find vehicle" - }, - "light_flash": { - "name": "Flash lights" - }, - "sound_horn": { - "name": "Sound horn" - } - }, - "lock": { - "lock": { - "name": "[%key:component::lock::title%]" - } - }, - "number": { - "target_soc": { - "name": "Target SoC" - } - }, - "select": { - "ac_limit": { - "name": "AC charging limit" - }, - "charging_mode": { - "name": "Charging mode", - "state": { - "delayed_charging": "Delayed charging", - "immediate_charging": "Immediate charging", - "no_action": "No action" - } - } - }, - "sensor": { - "ac_current_limit": { - "name": "AC current limit" - }, - "charging_end_time": { - "name": "Charging end time" - }, - "charging_start_time": { - "name": "Charging start time" - }, - "charging_status": { - "name": "Charging status", - "state": { - "charging": "[%key:common::state::charging%]", - "complete": "Complete", - "default": "Default", - "error": "[%key:common::state::error%]", - "finished_fully_charged": "Finished, fully charged", - "finished_not_full": "Finished, not full", - "fully_charged": "Fully charged", - "invalid": "Invalid", - "not_charging": "Not charging", - "plugged_in": "Plugged in", - "target_reached": "Target reached", - "waiting_for_charging": "Waiting for charging" - } - }, - "charging_target": { - "name": "Charging target" - }, - "climate_status": { - "name": "Climate status", - "state": { - "cooling": "Cooling", - "heating": "Heating", - "inactive": "Inactive", - "standby": "[%key:common::state::standby%]", - "ventilation": "Ventilation" - } - }, - "front_left_current_pressure": { - "name": "Front left tire pressure" - }, - "front_left_target_pressure": { - "name": "Front left target pressure" - }, - "front_right_current_pressure": { - "name": "Front right tire pressure" - }, - "front_right_target_pressure": { - "name": "Front right target pressure" - }, - "mileage": { - "name": "Mileage" - }, - "rear_left_current_pressure": { - "name": "Rear left tire pressure" - }, - "rear_left_target_pressure": { - "name": "Rear left target pressure" - }, - "rear_right_current_pressure": { - "name": "Rear right tire pressure" - }, - "rear_right_target_pressure": { - "name": "Rear right target pressure" - }, - "remaining_battery_percent": { - "name": "Remaining battery percent" - }, - "remaining_fuel": { - "name": "Remaining fuel" - }, - "remaining_fuel_percent": { - "name": "Remaining fuel percent" - }, - "remaining_range_electric": { - "name": "Remaining range electric" - }, - "remaining_range_fuel": { - "name": "Remaining range fuel" - }, - "remaining_range_total": { - "name": "Remaining range total" - } - }, - "switch": { - "charging": { - "name": "Charging" - }, - "climate": { - "name": "Climate" - } - } - }, - "exceptions": { - "invalid_auth": { - "message": "[%key:common::config_flow::error::invalid_auth%]" - }, - "invalid_poi": { - "message": "Invalid data for point of interest: {poi_exception}" - }, - "missing_captcha": { - "message": "Login requires captcha validation" - }, - "remote_service_error": { - "message": "Error executing remote service on vehicle. {exception}" - }, - "update_failed": { - "message": "Error updating vehicle data. {exception}" - } - }, - "options": { - "step": { - "account_options": { - "data": { - "read_only": "Read-only mode" - }, - "data_description": { - "read_only": "Only retrieve values and send POI data, but don't offer any services that can change the vehicle state." - } - } - } - }, - "selector": { - "regions": { - "options": { - "china": "China", - "north_america": "North America", - "rest_of_world": "Rest of world" - } - } - } -} diff --git a/homeassistant/components/bmw_connected_drive/switch.py b/homeassistant/components/bmw_connected_drive/switch.py deleted file mode 100644 index 44f6eb4bbb0d2c..00000000000000 --- a/homeassistant/components/bmw_connected_drive/switch.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Switch platform for BMW.""" - -from collections.abc import Callable, Coroutine -from dataclasses import dataclass -import logging -from typing import Any - -from bimmer_connected.models import MyBMWAPIError -from bimmer_connected.vehicle import MyBMWVehicle -from bimmer_connected.vehicle.fuel_and_battery import ChargingState - -from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from . import DOMAIN, BMWConfigEntry -from .coordinator import BMWDataUpdateCoordinator -from .entity import BMWBaseEntity - -PARALLEL_UPDATES = 1 - -_LOGGER = logging.getLogger(__name__) - - -@dataclass(frozen=True, kw_only=True) -class BMWSwitchEntityDescription(SwitchEntityDescription): - """Describes BMW switch entity.""" - - value_fn: Callable[[MyBMWVehicle], bool] - remote_service_on: Callable[[MyBMWVehicle], Coroutine[Any, Any, Any]] - remote_service_off: Callable[[MyBMWVehicle], Coroutine[Any, Any, Any]] - is_available: Callable[[MyBMWVehicle], bool] = lambda _: False - dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None - - -CHARGING_STATE_ON = { - ChargingState.CHARGING, - ChargingState.COMPLETE, - ChargingState.FULLY_CHARGED, - ChargingState.FINISHED_FULLY_CHARGED, - ChargingState.FINISHED_NOT_FULL, - ChargingState.TARGET_REACHED, -} - -NUMBER_TYPES: list[BMWSwitchEntityDescription] = [ - BMWSwitchEntityDescription( - key="climate", - translation_key="climate", - is_available=lambda v: v.is_remote_climate_stop_enabled, - value_fn=lambda v: v.climate.is_climate_on, - remote_service_on=lambda v: v.remote_services.trigger_remote_air_conditioning(), - remote_service_off=lambda v: ( - v.remote_services.trigger_remote_air_conditioning_stop() - ), - ), - BMWSwitchEntityDescription( - key="charging", - translation_key="charging", - is_available=lambda v: v.is_remote_charge_stop_enabled, - value_fn=lambda v: v.fuel_and_battery.charging_status in CHARGING_STATE_ON, - remote_service_on=lambda v: v.remote_services.trigger_charge_start(), - remote_service_off=lambda v: v.remote_services.trigger_charge_stop(), - ), -] - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: BMWConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up the MyBMW switch from config entry.""" - coordinator = config_entry.runtime_data - - entities: list[BMWSwitch] = [] - - for vehicle in coordinator.account.vehicles: - if not coordinator.read_only: - entities.extend( - [ - BMWSwitch(coordinator, vehicle, description) - for description in NUMBER_TYPES - if description.is_available(vehicle) - ] - ) - async_add_entities(entities) - - -class BMWSwitch(BMWBaseEntity, SwitchEntity): - """Representation of BMW Switch entity.""" - - entity_description: BMWSwitchEntityDescription - - def __init__( - self, - coordinator: BMWDataUpdateCoordinator, - vehicle: MyBMWVehicle, - description: BMWSwitchEntityDescription, - ) -> None: - """Initialize an BMW Switch.""" - super().__init__(coordinator, vehicle) - self.entity_description = description - self._attr_unique_id = f"{vehicle.vin}-{description.key}" - - @property - def is_on(self) -> bool: - """Return the entity value to represent the entity state.""" - return self.entity_description.value_fn(self.vehicle) - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the switch on.""" - try: - await self.entity_description.remote_service_on(self.vehicle) - except MyBMWAPIError as ex: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="remote_service_error", - translation_placeholders={"exception": str(ex)}, - ) from ex - self.coordinator.async_update_listeners() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the switch off.""" - try: - await self.entity_description.remote_service_off(self.vehicle) - except MyBMWAPIError as ex: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="remote_service_error", - translation_placeholders={"exception": str(ex)}, - ) from ex - self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bond/strings.json b/homeassistant/components/bond/strings.json index cab9546bb2a9c0..b27aab415573db 100644 --- a/homeassistant/components/bond/strings.json +++ b/homeassistant/components/bond/strings.json @@ -37,8 +37,8 @@ "name": "Entity" }, "speed": { - "description": "Fan Speed as %.", - "name": "Fan Speed" + "description": "The fan speed as a percentage.", + "name": "Fan speed" } }, "name": "Set fan speed tracked state" @@ -47,7 +47,7 @@ "description": "Sets the tracked brightness state of a Bond light.", "fields": { "brightness": { - "description": "Brightness.", + "description": "The tracked brightness of the light.", "name": "Brightness" }, "entity_id": { @@ -79,22 +79,22 @@ "name": "Entity" }, "power_state": { - "description": "Power state.", + "description": "The tracked power state.", "name": "Power state" } }, "name": "Set switch power tracked state" }, "start_decreasing_brightness": { - "description": "Starts decreasing the brightness of the light (deprecated).", + "description": "Starts decreasing the brightness of a light (deprecated).", "name": "Start decreasing brightness" }, "start_increasing_brightness": { - "description": "Starts increasing the brightness of the light (deprecated).", + "description": "Starts increasing the brightness of a light (deprecated).", "name": "Start increasing brightness" }, "stop": { - "description": "Stops any in-progress action and empty the queue (deprecated).", + "description": "Stops any in-progress action and empties the queue (deprecated).", "name": "[%key:common::action::stop%]" } } diff --git a/homeassistant/components/bosch_shc/binary_sensor.py b/homeassistant/components/bosch_shc/binary_sensor.py index 30d823fd608bb6..e7818a1007a9f4 100644 --- a/homeassistant/components/bosch_shc/binary_sensor.py +++ b/homeassistant/components/bosch_shc/binary_sensor.py @@ -77,7 +77,7 @@ def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: ) @property - def is_on(self): + def is_on(self) -> bool: """Return the state of the sensor.""" return self._device.state == SHCShutterContact.ShutterContactService.State.OPEN @@ -93,7 +93,7 @@ def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: self._attr_unique_id = f"{device.serial}_battery" @property - def is_on(self): + def is_on(self) -> bool: """Return the state of the sensor.""" return ( self._device.batterylevel != SHCBatteryDevice.BatteryLevelService.State.OK diff --git a/homeassistant/components/brands/__init__.py b/homeassistant/components/brands/__init__.py new file mode 100644 index 00000000000000..0cfe254904f323 --- /dev/null +++ b/homeassistant/components/brands/__init__.py @@ -0,0 +1,291 @@ +"""The Brands integration.""" + +from __future__ import annotations + +from collections import deque +from http import HTTPStatus +import logging +from pathlib import Path +from random import SystemRandom +import time +from typing import Any, Final + +from aiohttp import ClientError, hdrs, web +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView +from homeassistant.core import HomeAssistant, callback, valid_domain +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import ConfigType +from homeassistant.loader import async_get_custom_components + +from .const import ( + ALLOWED_IMAGES, + BRANDS_CDN_URL, + CACHE_TTL, + CATEGORY_RE, + CDN_TIMEOUT, + DOMAIN, + HARDWARE_IMAGE_RE, + IMAGE_FALLBACKS, + PLACEHOLDER, + TOKEN_CHANGE_INTERVAL, +) + +_LOGGER = logging.getLogger(__name__) +_RND: Final = SystemRandom() + +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Brands integration.""" + access_tokens: deque[str] = deque([], 2) + access_tokens.append(hex(_RND.getrandbits(256))[2:]) + hass.data[DOMAIN] = access_tokens + + @callback + 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) + + hass.http.register_view(BrandsIntegrationView(hass)) + hass.http.register_view(BrandsHardwareView(hass)) + websocket_api.async_register_command(hass, ws_access_token) + return True + + +@callback +@websocket_api.websocket_command({vol.Required("type"): "brands/access_token"}) +def ws_access_token( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Return the current brands access token.""" + access_tokens: deque[str] = hass.data[DOMAIN] + connection.send_result(msg["id"], {"token": access_tokens[-1]}) + + +def _read_cached_file_with_marker( + cache_path: Path, +) -> tuple[bytes | None, float] | None: + """Read a cached file, distinguishing between content and 404 markers. + + Returns (content, mtime) where content is None for 404 markers (empty files). + Returns None if the file does not exist at all. + """ + if not cache_path.is_file(): + return None + mtime = cache_path.stat().st_mtime + data = cache_path.read_bytes() + if not data: + # Empty file is a 404 marker + return (None, mtime) + return (data, mtime) + + +def _write_cache_file(cache_path: Path, data: bytes) -> None: + """Write data to cache file, creating directories as needed.""" + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_path.write_bytes(data) + + +def _read_brand_file(brand_dir: Path, image: str) -> bytes | None: + """Read a brand image, trying fallbacks in a single I/O pass.""" + for candidate in (image, *IMAGE_FALLBACKS.get(image, ())): + file_path = brand_dir / candidate + if file_path.is_file(): + return file_path.read_bytes() + return None + + +class _BrandsBaseView(HomeAssistantView): + """Base view for serving brand images.""" + + requires_auth = False + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the view.""" + self._hass = hass + self._cache_dir = Path(hass.config.cache_path(DOMAIN)) + + def _authenticate(self, request: web.Request) -> None: + """Authenticate the request using Bearer token or query token.""" + access_tokens: deque[str] = self._hass.data[DOMAIN] + authenticated = ( + request[KEY_AUTHENTICATED] or request.query.get("token") in access_tokens + ) + if not authenticated: + if hdrs.AUTHORIZATION in request.headers: + raise web.HTTPUnauthorized + raise web.HTTPForbidden + + async def _serve_from_custom_integration( + self, + domain: str, + image: str, + ) -> web.Response | None: + """Try to serve a brand image from a custom integration.""" + custom_components = await async_get_custom_components(self._hass) + if (integration := custom_components.get(domain)) is None: + return None + if not integration.has_branding: + return None + + brand_dir = Path(integration.file_path) / "brand" + + data = await self._hass.async_add_executor_job( + _read_brand_file, brand_dir, image + ) + if data is not None: + return self._build_response(data) + + return None + + async def _serve_from_cache_or_cdn( + self, + cdn_path: str, + cache_subpath: str, + *, + fallback_placeholder: bool = True, + ) -> web.Response: + """Serve from disk cache, fetching from CDN if needed.""" + cache_path = self._cache_dir / cache_subpath + now = time.time() + + # Try disk cache + result = await self._hass.async_add_executor_job( + _read_cached_file_with_marker, cache_path + ) + if result is not None: + data, mtime = result + # Schedule background refresh if stale + if now - mtime > CACHE_TTL: + self._hass.async_create_background_task( + self._fetch_and_cache(cdn_path, cache_path), + f"brands_refresh_{cache_subpath}", + ) + else: + # Cache miss - fetch from CDN + data = await self._fetch_and_cache(cdn_path, cache_path) + + if data is None: + if fallback_placeholder: + return await self._serve_placeholder( + image=cache_subpath.rsplit("/", 1)[-1] + ) + return web.Response(status=HTTPStatus.NOT_FOUND) + return self._build_response(data) + + async def _fetch_and_cache( + self, + cdn_path: str, + cache_path: Path, + ) -> bytes | None: + """Fetch from CDN and write to cache. Returns data or None on 404.""" + url = f"{BRANDS_CDN_URL}/{cdn_path}" + session = async_get_clientsession(self._hass) + try: + resp = await session.get(url, timeout=CDN_TIMEOUT) + except ClientError, TimeoutError: + _LOGGER.debug("Failed to fetch brand from CDN: %s", cdn_path) + return None + + if resp.status == HTTPStatus.NOT_FOUND: + # Cache the 404 as empty file + await self._hass.async_add_executor_job(_write_cache_file, cache_path, b"") + return None + + if resp.status != HTTPStatus.OK: + _LOGGER.debug("Unexpected CDN response %s for %s", resp.status, cdn_path) + return None + + data = await resp.read() + await self._hass.async_add_executor_job(_write_cache_file, cache_path, data) + return data + + async def _serve_placeholder(self, image: str) -> web.Response: + """Serve a placeholder image.""" + return await self._serve_from_cache_or_cdn( + cdn_path=f"_/{PLACEHOLDER}/{image}", + cache_subpath=f"integrations/{PLACEHOLDER}/{image}", + fallback_placeholder=False, + ) + + @staticmethod + def _build_response(data: bytes) -> web.Response: + """Build a response with proper headers.""" + return web.Response( + body=data, + content_type="image/png", + ) + + +class BrandsIntegrationView(_BrandsBaseView): + """Serve integration brand images.""" + + name = "api:brands:integration" + url = "/api/brands/integration/{domain}/{image}" + + async def get( + self, + request: web.Request, + domain: str, + image: str, + ) -> web.Response: + """Handle GET request for an integration brand image.""" + self._authenticate(request) + + if not valid_domain(domain) or image not in ALLOWED_IMAGES: + return web.Response(status=HTTPStatus.NOT_FOUND) + + use_placeholder = request.query.get("placeholder") != "no" + + # 1. Try custom integration local files + if ( + response := await self._serve_from_custom_integration(domain, image) + ) is not None: + return response + + # 2. Try cache / CDN (always use direct path for proper 404 caching) + return await self._serve_from_cache_or_cdn( + cdn_path=f"brands/{domain}/{image}", + cache_subpath=f"integrations/{domain}/{image}", + fallback_placeholder=use_placeholder, + ) + + +class BrandsHardwareView(_BrandsBaseView): + """Serve hardware brand images.""" + + name = "api:brands:hardware" + url = "/api/brands/hardware/{category}/{image:.+}" + + async def get( + self, + request: web.Request, + category: str, + image: str, + ) -> web.Response: + """Handle GET request for a hardware brand image.""" + self._authenticate(request) + + if not CATEGORY_RE.match(category): + return web.Response(status=HTTPStatus.NOT_FOUND) + # Hardware images have dynamic names like "manufacturer_model.png" + # Validate it ends with .png and contains only safe characters + if not HARDWARE_IMAGE_RE.match(image): + return web.Response(status=HTTPStatus.NOT_FOUND) + + cache_subpath = f"hardware/{category}/{image}" + + return await self._serve_from_cache_or_cdn( + cdn_path=cache_subpath, + cache_subpath=cache_subpath, + ) diff --git a/homeassistant/components/brands/const.py b/homeassistant/components/brands/const.py new file mode 100644 index 00000000000000..fd2c9672a9e8b7 --- /dev/null +++ b/homeassistant/components/brands/const.py @@ -0,0 +1,57 @@ +"""Constants for the Brands integration.""" + +from __future__ import annotations + +from datetime import timedelta +import re +from typing import Final + +from aiohttp import ClientTimeout + +DOMAIN: Final = "brands" + +# CDN +BRANDS_CDN_URL: Final = "https://brands.home-assistant.io" +CDN_TIMEOUT: Final = ClientTimeout(total=10) +PLACEHOLDER: Final = "_placeholder" + +# Caching +CACHE_TTL: Final = 30 * 24 * 60 * 60 # 30 days in seconds + +# Access token +TOKEN_CHANGE_INTERVAL: Final = timedelta(minutes=30) + +# Validation +CATEGORY_RE: Final = re.compile(r"^[a-z0-9_]+$") +HARDWARE_IMAGE_RE: Final = re.compile(r"^[a-z0-9_-]+\.png$") + +# Images and fallback chains +ALLOWED_IMAGES: Final = frozenset( + { + "icon.png", + "logo.png", + "icon@2x.png", + "logo@2x.png", + "dark_icon.png", + "dark_logo.png", + "dark_icon@2x.png", + "dark_logo@2x.png", + } +) + +# Fallback chains for image resolution, mirroring the brands CDN build logic. +# When a requested image is not found, we try each fallback in order. +IMAGE_FALLBACKS: Final[dict[str, list[str]]] = { + "logo.png": ["icon.png"], + "icon@2x.png": ["icon.png"], + "logo@2x.png": ["logo.png", "icon.png"], + "dark_icon.png": ["icon.png"], + "dark_logo.png": ["dark_icon.png", "logo.png", "icon.png"], + "dark_icon@2x.png": ["icon@2x.png", "icon.png"], + "dark_logo@2x.png": [ + "dark_icon@2x.png", + "logo@2x.png", + "logo.png", + "icon.png", + ], +} diff --git a/homeassistant/components/brands/manifest.json b/homeassistant/components/brands/manifest.json new file mode 100644 index 00000000000000..ad3bbbf8da7f61 --- /dev/null +++ b/homeassistant/components/brands/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "brands", + "name": "Brands", + "codeowners": ["@home-assistant/core"], + "config_flow": false, + "dependencies": ["http", "websocket_api"], + "documentation": "https://www.home-assistant.io/integrations/brands", + "integration_type": "system", + "quality_scale": "internal" +} diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py index dda4231dd30960..4f1a10c26213c1 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -10,7 +10,7 @@ from brother import BrotherSensors from homeassistant.components.sensor import ( - DOMAIN as PLATFORM, + DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -314,7 +314,7 @@ async def async_setup_entry( entity_registry = er.async_get(hass) old_unique_id = f"{coordinator.brother.serial.lower()}_b/w_counter" if entity_id := entity_registry.async_get_entity_id( - PLATFORM, DOMAIN, old_unique_id + SENSOR_DOMAIN, DOMAIN, old_unique_id ): new_unique_id = f"{coordinator.brother.serial.lower()}_bw_counter" _LOGGER.debug( diff --git a/homeassistant/components/bsblan/__init__.py b/homeassistant/components/bsblan/__init__.py index 1abe376826baf1..0520cb8039eb10 100644 --- a/homeassistant/components/bsblan/__init__.py +++ b/homeassistant/components/bsblan/__init__.py @@ -1,4 +1,4 @@ -"""The BSB-Lan integration.""" +"""The BSB-LAN integration.""" import asyncio import dataclasses @@ -32,11 +32,11 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType -from .const import CONF_PASSKEY, DOMAIN +from .const import CONF_PASSKEY, DOMAIN, LOGGER from .coordinator import BSBLanFastCoordinator, BSBLanSlowCoordinator from .services import async_setup_services -PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER] +PLATFORMS = [Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -52,17 +52,17 @@ class BSBLanData: client: BSBLAN device: Device info: Info - static: StaticState + static: StaticState | None async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the BSB-Lan integration.""" + """Set up the BSB-LAN integration.""" async_setup_services(hass) return True async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bool: - """Set up BSB-Lan from a config entry.""" + """Set up BSB-LAN from a config entry.""" # create config using BSBLANConfig config = BSBLANConfig( @@ -82,11 +82,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo # the connection by fetching firmware version await bsblan.initialize() - # Fetch device metadata in parallel for faster startup - device, info, static = await asyncio.gather( + # Fetch required device metadata in parallel for faster startup + device, info = await asyncio.gather( bsblan.device(), bsblan.info(), - bsblan.static_values(), ) except BSBLANConnectionError as err: raise ConfigEntryNotReady( @@ -111,6 +110,16 @@ 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 + # Create coordinators with the already-initialized client fast_coordinator = BSBLanFastCoordinator(hass, entry, bsblan) slow_coordinator = BSBLanSlowCoordinator(hass, entry, bsblan) diff --git a/homeassistant/components/bsblan/button.py b/homeassistant/components/bsblan/button.py new file mode 100644 index 00000000000000..9d3261814a2a58 --- /dev/null +++ b/homeassistant/components/bsblan/button.py @@ -0,0 +1,59 @@ +"""Button platform for BSB-Lan integration.""" + +from __future__ import annotations + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import BSBLanConfigEntry, BSBLanData +from .coordinator import BSBLanFastCoordinator +from .entity import BSBLanEntity +from .helpers import async_sync_device_time + +PARALLEL_UPDATES = 1 + +BUTTON_DESCRIPTIONS: tuple[ButtonEntityDescription, ...] = ( + ButtonEntityDescription( + key="sync_time", + translation_key="sync_time", + entity_category=EntityCategory.CONFIG, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BSBLanConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up BSB-Lan button entities from a config entry.""" + data = entry.runtime_data + + async_add_entities( + BSBLanButtonEntity(data.fast_coordinator, data, description) + for description in BUTTON_DESCRIPTIONS + ) + + +class BSBLanButtonEntity(BSBLanEntity, ButtonEntity): + """Defines a BSB-Lan button entity.""" + + entity_description: ButtonEntityDescription + + def __init__( + self, + coordinator: BSBLanFastCoordinator, + data: BSBLanData, + description: ButtonEntityDescription, + ) -> None: + """Initialize BSB-Lan button entity.""" + super().__init__(coordinator, data) + self.entity_description = description + self._attr_unique_id = f"{data.device.MAC}-{description.key}" + self._data = data + + async def async_press(self) -> None: + """Handle the button press.""" + await async_sync_device_time(self._data.client, self._data.device.name) diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index c6de76d056b4ce..8ae03e0a7a2ee6 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -21,7 +21,6 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util.enum import try_parse_enum from . import BSBLanConfigEntry, BSBLanData from .const import ATTR_TARGET_TEMPERATURE, DOMAIN @@ -40,15 +39,15 @@ PRESET_NONE, ] -# Mapping from Home Assistant HVACMode to BSB-Lan integer values -# BSB-Lan uses: 0=off, 1=auto, 2=eco/reduced, 3=heat/comfort +# Mapping from Home Assistant HVACMode to BSB-LAN integer values +# BSB-LAN uses: 0=off, 1=auto, 2=eco/reduced, 3=heat/comfort HA_TO_BSBLAN_HVAC_MODE: Final[dict[HVACMode, int]] = { HVACMode.OFF: 0, HVACMode.AUTO: 1, HVACMode.HEAT: 3, } -# Mapping from BSB-Lan integer values to Home Assistant HVACMode +# Mapping from BSB-LAN integer values to Home Assistant HVACMode BSBLAN_TO_HA_HVAC_MODE: Final[dict[int, HVACMode]] = { 0: HVACMode.OFF, 1: HVACMode.AUTO, @@ -70,7 +69,6 @@ async def async_setup_entry( class BSBLANClimate(BSBLanEntity, ClimateEntity): """Defines a BSBLAN climate device.""" - _attr_has_entity_name = True _attr_name = None # Determine preset modes _attr_supported_features = ( @@ -92,28 +90,29 @@ def __init__( self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate" # Set temperature range if available, otherwise use Home Assistant defaults - if data.static.min_temp is not None and data.static.min_temp.value is not None: - self._attr_min_temp = data.static.min_temp.value - if data.static.max_temp is not None and data.static.max_temp.value is not None: - self._attr_max_temp = data.static.max_temp.value + if (static := data.static) 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 current_temperature(self) -> float | None: """Return the current temperature.""" - if self.coordinator.data.state.current_temperature is None: + if (current_temp := self.coordinator.data.state.current_temperature) is None: return None - return self.coordinator.data.state.current_temperature.value + return current_temp.value @property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" - if self.coordinator.data.state.target_temperature is None: + if (target_temp := self.coordinator.data.state.target_temperature) is None: return None - return self.coordinator.data.state.target_temperature.value + return target_temp.value @property - def _hvac_mode_value(self) -> int | str | None: + 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: return None @@ -124,16 +123,14 @@ def hvac_mode(self) -> HVACMode | None: """Return hvac operation ie. heat, cool mode.""" if (hvac_mode_value := self._hvac_mode_value) is None: return None - # BSB-Lan returns integer values: 0=off, 1=auto, 2=eco, 3=heat - if isinstance(hvac_mode_value, int): - return BSBLAN_TO_HA_HVAC_MODE.get(hvac_mode_value) - return try_parse_enum(HVACMode, hvac_mode_value) + return BSBLAN_TO_HA_HVAC_MODE.get(hvac_mode_value) @property def hvac_action(self) -> HVACAction | None: """Return the current running hvac action.""" - action = self.coordinator.data.state.hvac_action - if not action or not isinstance(action.value, int): + if ( + action := self.coordinator.data.state.hvac_action + ) is None or action.value is None: return None category = get_hvac_action_category(action.value) return HVACAction(category.name.lower()) @@ -141,7 +138,7 @@ def hvac_action(self) -> HVACAction | None: @property def preset_mode(self) -> str | None: """Return the current preset mode.""" - # BSB-Lan mode 2 is eco/reduced mode + # BSB-LAN mode 2 is eco/reduced mode if self._hvac_mode_value == 2: return PRESET_ECO return PRESET_NONE @@ -166,7 +163,7 @@ async def async_set_data(self, **kwargs: Any) -> None: if ATTR_HVAC_MODE in kwargs: data[ATTR_HVAC_MODE] = HA_TO_BSBLAN_HVAC_MODE[kwargs[ATTR_HVAC_MODE]] if ATTR_PRESET_MODE in kwargs: - # eco preset uses BSB-Lan mode 2, none preset uses mode 1 (auto) + # eco preset uses BSB-LAN mode 2, none preset uses mode 1 (auto) if kwargs[ATTR_PRESET_MODE] == PRESET_ECO: data[ATTR_HVAC_MODE] = 2 elif kwargs[ATTR_PRESET_MODE] == PRESET_NONE: diff --git a/homeassistant/components/bsblan/config_flow.py b/homeassistant/components/bsblan/config_flow.py index 8848b5a3c4cdc9..01024a07e42c9e 100644 --- a/homeassistant/components/bsblan/config_flow.py +++ b/homeassistant/components/bsblan/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow for BSB-Lan integration.""" +"""Config flow for BSB-LAN integration.""" from __future__ import annotations @@ -183,90 +183,122 @@ async def async_step_reauth_confirm( existing_entry = self._get_reauth_entry() if user_input is None: - # Preserve existing values as defaults return self.async_show_form( step_id="reauth_confirm", - data_schema=vol.Schema( - { - vol.Optional( - CONF_PASSKEY, - default=existing_entry.data.get( - CONF_PASSKEY, vol.UNDEFINED - ), - ): str, - vol.Optional( - CONF_USERNAME, - default=existing_entry.data.get( - CONF_USERNAME, vol.UNDEFINED - ), - ): str, - vol.Optional( - CONF_PASSWORD, - default=vol.UNDEFINED, - ): str, - } - ), + data_schema=self._build_credentials_schema(existing_entry.data), ) - # Combine existing data with the user's new input for validation. - # This correctly handles adding, changing, and clearing credentials. - config_data = existing_entry.data.copy() - config_data.update(user_input) - - self.host = config_data[CONF_HOST] - self.port = config_data[CONF_PORT] - self.passkey = config_data.get(CONF_PASSKEY) - self.username = config_data.get(CONF_USERNAME) - self.password = config_data.get(CONF_PASSWORD) + # Merge existing data with user input for validation + validate_data = {**existing_entry.data, **user_input} + errors = await self._async_validate_credentials(validate_data) - try: - await self._get_bsblan_info(raise_on_progress=False, is_reauth=True) - except BSBLANAuthError: + if errors: return self.async_show_form( step_id="reauth_confirm", - data_schema=vol.Schema( - { - vol.Optional( - CONF_PASSKEY, - default=user_input.get(CONF_PASSKEY, vol.UNDEFINED), - ): str, - vol.Optional( - CONF_USERNAME, - default=user_input.get(CONF_USERNAME, vol.UNDEFINED), - ): str, - vol.Optional( - CONF_PASSWORD, - default=vol.UNDEFINED, - ): str, - } - ), - errors={"base": "invalid_auth"}, + data_schema=self._build_credentials_schema(user_input), + errors=errors, ) - except BSBLANError: + + return self.async_update_reload_and_abort( + existing_entry, data_updates=user_input, reason="reauth_successful" + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration flow.""" + existing_entry = self._get_reconfigure_entry() + + if user_input is None: return self.async_show_form( - step_id="reauth_confirm", - data_schema=vol.Schema( - { - vol.Optional( - CONF_PASSKEY, - default=user_input.get(CONF_PASSKEY, vol.UNDEFINED), - ): str, - vol.Optional( - CONF_USERNAME, - default=user_input.get(CONF_USERNAME, vol.UNDEFINED), - ): str, - vol.Optional( - CONF_PASSWORD, - default=vol.UNDEFINED, - ): str, - } - ), - errors={"base": "cannot_connect"}, + step_id="reconfigure", + data_schema=self._build_connection_schema(existing_entry.data), ) - # Update only the fields that were provided by the user + # Merge existing data with user input for validation + validate_data = {**existing_entry.data, **user_input} + errors = await self._async_validate_credentials(validate_data) + + if errors: + return self.async_show_form( + step_id="reconfigure", + data_schema=self._build_connection_schema(user_input), + errors=errors, + ) + + # Prevent reconfiguring to a different physical device + # it gets the unique ID from the device info when it validates credentials + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( - existing_entry, data_updates=user_input, reason="reauth_successful" + existing_entry, + data_updates=user_input, + reason="reconfigure_successful", + ) + + async def _async_validate_credentials(self, data: dict[str, Any]) -> dict[str, str]: + """Validate connection credentials and return errors dict.""" + self.host = data[CONF_HOST] + self.port = data.get(CONF_PORT, DEFAULT_PORT) + self.passkey = data.get(CONF_PASSKEY) + self.username = data.get(CONF_USERNAME) + self.password = data.get(CONF_PASSWORD) + + errors: dict[str, str] = {} + try: + await self._get_bsblan_info(raise_on_progress=False, is_reauth=True) + except BSBLANAuthError: + errors["base"] = "invalid_auth" + except BSBLANError: + errors["base"] = "cannot_connect" + return errors + + @callback + def _build_credentials_schema(self, defaults: Mapping[str, Any]) -> vol.Schema: + """Build schema for credentials-only forms (reauth).""" + return vol.Schema( + { + vol.Optional( + CONF_PASSKEY, + default=defaults.get(CONF_PASSKEY) or vol.UNDEFINED, + ): str, + vol.Optional( + CONF_USERNAME, + default=defaults.get(CONF_USERNAME) or vol.UNDEFINED, + ): str, + vol.Optional( + CONF_PASSWORD, + default=vol.UNDEFINED, + ): str, + } + ) + + @callback + def _build_connection_schema(self, defaults: Mapping[str, Any]) -> vol.Schema: + """Build schema for full connection forms (user and reconfigure).""" + return vol.Schema( + { + vol.Required( + CONF_HOST, + default=defaults.get(CONF_HOST, vol.UNDEFINED), + ): str, + vol.Optional( + CONF_PORT, + default=defaults.get(CONF_PORT, DEFAULT_PORT), + ): int, + vol.Optional( + CONF_PASSKEY, + default=defaults.get(CONF_PASSKEY) or vol.UNDEFINED, + ): str, + vol.Optional( + CONF_USERNAME, + default=defaults.get(CONF_USERNAME) or vol.UNDEFINED, + ): str, + vol.Optional( + CONF_PASSWORD, + default=vol.UNDEFINED, + ): str, + } ) @callback @@ -274,32 +306,9 @@ def _show_setup_form( self, errors: dict | None = None, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Show the setup form to the user.""" - # Preserve user input if provided, otherwise use defaults - defaults = user_input or {} - return self.async_show_form( step_id="user", - data_schema=vol.Schema( - { - vol.Required( - CONF_HOST, default=defaults.get(CONF_HOST, vol.UNDEFINED) - ): str, - vol.Optional( - CONF_PORT, default=defaults.get(CONF_PORT, DEFAULT_PORT) - ): int, - vol.Optional( - CONF_PASSKEY, default=defaults.get(CONF_PASSKEY, vol.UNDEFINED) - ): str, - vol.Optional( - CONF_USERNAME, - default=defaults.get(CONF_USERNAME, vol.UNDEFINED), - ): str, - vol.Optional( - CONF_PASSWORD, - default=defaults.get(CONF_PASSWORD, vol.UNDEFINED), - ): str, - } - ), + data_schema=self._build_connection_schema(user_input or {}), errors=errors or {}, ) diff --git a/homeassistant/components/bsblan/const.py b/homeassistant/components/bsblan/const.py index 24c793eb0e14e0..8dfdc180089da4 100644 --- a/homeassistant/components/bsblan/const.py +++ b/homeassistant/components/bsblan/const.py @@ -1,4 +1,4 @@ -"""Constants for the BSB-Lan integration.""" +"""Constants for the BSB-LAN integration.""" from __future__ import annotations diff --git a/homeassistant/components/bsblan/coordinator.py b/homeassistant/components/bsblan/coordinator.py index b39376f6f02239..e1869d5f772e94 100644 --- a/homeassistant/components/bsblan/coordinator.py +++ b/homeassistant/components/bsblan/coordinator.py @@ -1,7 +1,10 @@ -"""DataUpdateCoordinator for the BSB-Lan integration.""" +"""DataUpdateCoordinator for the BSB-LAN integration.""" + +from __future__ import annotations from dataclasses import dataclass from datetime import timedelta +from typing import TYPE_CHECKING from bsblan import ( BSBLAN, @@ -14,7 +17,6 @@ State, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -22,10 +24,18 @@ from .const import DOMAIN, LOGGER, SCAN_INTERVAL_FAST, SCAN_INTERVAL_SLOW +if TYPE_CHECKING: + from . import BSBLanConfigEntry + # Filter lists for optimized API calls - only fetch parameters we actually use # This significantly reduces response time (~0.2s per parameter saved) -STATE_INCLUDE = ["current_temperature", "target_temperature", "hvac_mode"] -SENSOR_INCLUDE = ["current_temperature", "outside_temperature"] +STATE_INCLUDE = [ + "current_temperature", + "target_temperature", + "hvac_mode", + "hvac_action", +] +SENSOR_INCLUDE = ["current_temperature", "outside_temperature", "total_energy"] DHW_STATE_INCLUDE = [ "operating_mode", "nominal_setpoint", @@ -52,19 +62,19 @@ class BSBLanSlowData: class BSBLanCoordinator[T](DataUpdateCoordinator[T]): - """Base BSB-Lan coordinator.""" + """Base BSB-LAN coordinator.""" - config_entry: ConfigEntry + config_entry: BSBLanConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: BSBLanConfigEntry, client: BSBLAN, name: str, update_interval: timedelta, ) -> None: - """Initialize the BSB-Lan coordinator.""" + """Initialize the BSB-LAN coordinator.""" super().__init__( hass, logger=LOGGER, @@ -76,15 +86,15 @@ def __init__( class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]): - """The BSB-Lan fast update coordinator for frequently changing data.""" + """The BSB-LAN fast update coordinator for frequently changing data.""" def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: BSBLanConfigEntry, client: BSBLAN, ) -> None: - """Initialize the BSB-Lan fast coordinator.""" + """Initialize the BSB-LAN fast coordinator.""" super().__init__( hass, config_entry, @@ -94,7 +104,7 @@ def __init__( ) async def _async_update_data(self) -> BSBLanFastData: - """Fetch fast-changing data from the BSB-Lan device.""" + """Fetch fast-changing data from the BSB-LAN device.""" try: # Client is already initialized in async_setup_entry # Use include filtering to only fetch parameters we actually use @@ -105,12 +115,15 @@ async def _async_update_data(self) -> BSBLanFastData: except BSBLANAuthError as err: raise ConfigEntryAuthFailed( - "Authentication failed for BSB-Lan device" + translation_domain=DOMAIN, + translation_key="coordinator_auth_error", ) from err except BSBLANConnectionError as err: host = self.config_entry.data[CONF_HOST] raise UpdateFailed( - f"Error while establishing connection with BSB-Lan device at {host}" + translation_domain=DOMAIN, + translation_key="coordinator_connection_error", + translation_placeholders={"host": host}, ) from err return BSBLanFastData( @@ -121,15 +134,15 @@ async def _async_update_data(self) -> BSBLanFastData: class BSBLanSlowCoordinator(BSBLanCoordinator[BSBLanSlowData]): - """The BSB-Lan slow update coordinator for infrequently changing data.""" + """The BSB-LAN slow update coordinator for infrequently changing data.""" def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: BSBLanConfigEntry, client: BSBLAN, ) -> None: - """Initialize the BSB-Lan slow coordinator.""" + """Initialize the BSB-LAN slow coordinator.""" super().__init__( hass, config_entry, @@ -139,7 +152,7 @@ def __init__( ) async def _async_update_data(self) -> BSBLanSlowData: - """Fetch slow-changing data from the BSB-Lan device.""" + """Fetch slow-changing data from the BSB-LAN device.""" try: # Client is already initialized in async_setup_entry # Use include filtering to only fetch parameters we actually use diff --git a/homeassistant/components/bsblan/diagnostics.py b/homeassistant/components/bsblan/diagnostics.py index 899dba5629a94e..55dedead85192b 100644 --- a/homeassistant/components/bsblan/diagnostics.py +++ b/homeassistant/components/bsblan/diagnostics.py @@ -17,24 +17,24 @@ async def async_get_config_entry_diagnostics( # Build diagnostic data from both coordinators diagnostics = { - "info": data.info.to_dict(), - "device": data.device.to_dict(), + "info": data.info.model_dump(), + "device": data.device.model_dump(), "fast_coordinator_data": { - "state": data.fast_coordinator.data.state.to_dict(), - "sensor": data.fast_coordinator.data.sensor.to_dict(), - "dhw": data.fast_coordinator.data.dhw.to_dict(), + "state": data.fast_coordinator.data.state.model_dump(), + "sensor": data.fast_coordinator.data.sensor.model_dump(), + "dhw": data.fast_coordinator.data.dhw.model_dump(), }, - "static": data.static.to_dict(), + "static": data.static.model_dump() if data.static is not None else None, } # Add DHW config and schedule from slow coordinator if available if data.slow_coordinator.data: slow_data = {} if data.slow_coordinator.data.dhw_config: - slow_data["dhw_config"] = data.slow_coordinator.data.dhw_config.to_dict() + slow_data["dhw_config"] = data.slow_coordinator.data.dhw_config.model_dump() if data.slow_coordinator.data.dhw_schedule: slow_data["dhw_schedule"] = ( - data.slow_coordinator.data.dhw_schedule.to_dict() + data.slow_coordinator.data.dhw_schedule.model_dump() ) if slow_data: diagnostics["slow_coordinator_data"] = slow_data diff --git a/homeassistant/components/bsblan/entity.py b/homeassistant/components/bsblan/entity.py index 5f5203ef8d0462..e95873ac85d996 100644 --- a/homeassistant/components/bsblan/entity.py +++ b/homeassistant/components/bsblan/entity.py @@ -32,6 +32,15 @@ def __init__(self, coordinator: _T, data: BSBLanData) -> None: 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, diff --git a/homeassistant/components/bsblan/helpers.py b/homeassistant/components/bsblan/helpers.py new file mode 100644 index 00000000000000..236d4825b7e98c --- /dev/null +++ b/homeassistant/components/bsblan/helpers.py @@ -0,0 +1,42 @@ +"""Helper functions for BSB-Lan integration.""" + +from __future__ import annotations + +from bsblan import BSBLAN, BSBLANError + +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util import dt as dt_util + +from .const import DOMAIN + + +async def async_sync_device_time(client: BSBLAN, device_name: str) -> None: + """Synchronize BSB-LAN device time with Home Assistant. + + Only updates if device time differs from Home Assistant time. + + Args: + client: The BSB-LAN client instance. + device_name: The name of the device (used in error messages). + + Raises: + HomeAssistantError: If the time sync operation fails. + + """ + try: + device_time = await client.time() + current_time = dt_util.now() + current_time_str = current_time.strftime("%d.%m.%Y %H:%M:%S") + + # Only sync if device time differs from HA time + if device_time.time.value != current_time_str: + await client.set_time(current_time_str) + except BSBLANError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="sync_time_failed", + translation_placeholders={ + "device_name": device_name, + "error": str(err), + }, + ) from err diff --git a/homeassistant/components/bsblan/icons.json b/homeassistant/components/bsblan/icons.json index f58cebd1651e11..c4f02f88726dfc 100644 --- a/homeassistant/components/bsblan/icons.json +++ b/homeassistant/components/bsblan/icons.json @@ -1,4 +1,11 @@ { + "entity": { + "button": { + "sync_time": { + "default": "mdi:timer-sync-outline" + } + } + }, "services": { "set_hot_water_schedule": { "service": "mdi:calendar-clock" diff --git a/homeassistant/components/bsblan/manifest.json b/homeassistant/components/bsblan/manifest.json index 9205cad2a85249..ed60d5d151c602 100644 --- a/homeassistant/components/bsblan/manifest.json +++ b/homeassistant/components/bsblan/manifest.json @@ -1,13 +1,14 @@ { "domain": "bsblan", - "name": "BSB-Lan", + "name": "BSB-LAN", "codeowners": ["@liudger"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bsblan", "integration_type": "device", "iot_class": "local_polling", "loggers": ["bsblan"], - "requirements": ["python-bsblan==4.2.0"], + "quality_scale": "silver", + "requirements": ["python-bsblan==5.1.2"], "zeroconf": [ { "name": "bsb-lan*", diff --git a/homeassistant/components/bsblan/quality_scale.yaml b/homeassistant/components/bsblan/quality_scale.yaml new file mode 100644 index 00000000000000..be9efefd13735f --- /dev/null +++ b/homeassistant/components/bsblan/quality_scale.yaml @@ -0,0 +1,74 @@ +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: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: 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: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + This integration has a fixed single device. + 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-translations: done + exception-translations: done + icon-translations: todo + reconfiguration-flow: done + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + stale-devices: + status: exempt + comment: | + This integration has a fixed single device. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/bsblan/sensor.py b/homeassistant/components/bsblan/sensor.py index 1556e44a3d59d3..72f3fbab2d0f0c 100644 --- a/homeassistant/components/bsblan/sensor.py +++ b/homeassistant/components/bsblan/sensor.py @@ -1,4 +1,4 @@ -"""Support for BSB-Lan sensors.""" +"""Support for BSB-LAN sensors.""" from __future__ import annotations @@ -11,7 +11,7 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import UnitOfTemperature +from homeassistant.const import UnitOfEnergy, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType @@ -25,7 +25,7 @@ @dataclass(frozen=True, kw_only=True) class BSBLanSensorEntityDescription(SensorEntityDescription): - """Describes BSB-Lan sensor entity.""" + """Describes BSB-LAN sensor entity.""" value_fn: Callable[[BSBLanFastData], StateType] exists_fn: Callable[[BSBLanFastData], bool] = lambda data: True @@ -58,6 +58,21 @@ class BSBLanSensorEntityDescription(SensorEntityDescription): ), exists_fn=lambda data: data.sensor.outside_temperature is not None, ), + BSBLanSensorEntityDescription( + key="total_energy", + translation_key="total_energy", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, + entity_registry_enabled_default=False, + value_fn=lambda data: ( + data.sensor.total_energy.value + if data.sensor.total_energy is not None + else None + ), + exists_fn=lambda data: data.sensor.total_energy is not None, + ), ) @@ -66,7 +81,7 @@ async def async_setup_entry( entry: BSBLanConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up BSB-Lan sensor based on a config entry.""" + """Set up BSB-LAN sensor based on a config entry.""" data = entry.runtime_data # Only create sensors for available data points @@ -81,7 +96,7 @@ async def async_setup_entry( class BSBLanSensor(BSBLanEntity, SensorEntity): - """Defines a BSB-Lan sensor.""" + """Defines a BSB-LAN sensor.""" entity_description: BSBLanSensorEntityDescription @@ -90,7 +105,7 @@ def __init__( data: BSBLanData, description: BSBLanSensorEntityDescription, ) -> None: - """Initialize BSB-Lan sensor.""" + """Initialize BSB-LAN sensor.""" super().__init__(data.fast_coordinator, data) self.entity_description = description self._attr_unique_id = f"{data.device.MAC}-{description.key}" diff --git a/homeassistant/components/bsblan/services.py b/homeassistant/components/bsblan/services.py index 7768c790041b8c..62336f715c9c63 100644 --- a/homeassistant/components/bsblan/services.py +++ b/homeassistant/components/bsblan/services.py @@ -1,4 +1,4 @@ -"""Support for BSB-Lan services.""" +"""Support for BSB-LAN services.""" from __future__ import annotations @@ -13,9 +13,9 @@ 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 -from homeassistant.util import dt as dt_util from .const import DOMAIN +from .helpers import async_sync_device_time if TYPE_CHECKING: from . import BSBLanConfigEntry @@ -31,10 +31,6 @@ ATTR_SATURDAY_SLOTS = "saturday_slots" ATTR_SUNDAY_SLOTS = "sunday_slots" -# Service names -SERVICE_SET_HOT_WATER_SCHEDULE = "set_hot_water_schedule" -SERVICE_SYNC_TIME = "sync_time" - # Schema for a single time slot _SLOT_SCHEMA = vol.Schema( @@ -192,7 +188,7 @@ async def set_hot_water_schedule(service_call: ServiceCall) -> None: ) try: - # Call the BSB-Lan API to set the schedule + # Call the BSB-LAN API to set the schedule await client.set_hot_water_schedule(dhw_schedule) except BSBLANError as err: raise HomeAssistantError( @@ -245,25 +241,7 @@ async def async_sync_time(service_call: ServiceCall) -> None: ) client = entry.runtime_data.client - - try: - # Get current device time - device_time = await client.time() - current_time = dt_util.now() - current_time_str = current_time.strftime("%d.%m.%Y %H:%M:%S") - - # Only sync if device time differs from HA time - if device_time.time.value != current_time_str: - await client.set_time(current_time_str) - except BSBLANError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="sync_time_failed", - translation_placeholders={ - "device_name": device_entry.name or device_id, - "error": str(err), - }, - ) from err + await async_sync_device_time(client, device_entry.name or device_id) SYNC_TIME_SCHEMA = vol.Schema( @@ -275,17 +253,17 @@ async def async_sync_time(service_call: ServiceCall) -> None: @callback def async_setup_services(hass: HomeAssistant) -> None: - """Register the BSB-Lan services.""" + """Register the BSB-LAN services.""" hass.services.async_register( DOMAIN, - SERVICE_SET_HOT_WATER_SCHEDULE, + "set_hot_water_schedule", set_hot_water_schedule, schema=SERVICE_SET_HOT_WATER_SCHEDULE_SCHEMA, ) hass.services.async_register( DOMAIN, - SERVICE_SYNC_TIME, + "sync_time", async_sync_time, schema=SYNC_TIME_SCHEMA, ) diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json index f7a53654ab3c3f..bd663eb8ba7f92 100644 --- a/homeassistant/components/bsblan/strings.json +++ b/homeassistant/components/bsblan/strings.json @@ -3,7 +3,9 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "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%]", + "unique_id_mismatch": "The device you are trying to reconfigure is not the same as the one previously configured." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -22,8 +24,8 @@ "password": "[%key:component::bsblan::config::step::user::data_description::password%]", "username": "[%key:component::bsblan::config::step::user::data_description::username%]" }, - "description": "A BSB-Lan device was discovered at {host}. Please provide credentials if required.", - "title": "BSB-Lan device discovered" + "description": "A BSB-LAN device was discovered at {host}. Please provide credentials if required.", + "title": "BSB-LAN device discovered" }, "reauth_confirm": { "data": { @@ -36,9 +38,27 @@ "password": "[%key:component::bsblan::config::step::user::data_description::password%]", "username": "[%key:component::bsblan::config::step::user::data_description::username%]" }, - "description": "The BSB-Lan integration needs to re-authenticate with {name}", + "description": "The BSB-LAN integration needs to re-authenticate with {name}", "title": "[%key:common::config_flow::title::reauth%]" }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "passkey": "[%key:component::bsblan::config::step::user::data::passkey%]", + "password": "[%key:common::config_flow::data::password%]", + "port": "[%key:common::config_flow::data::port%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "[%key:component::bsblan::config::step::user::data_description::host%]", + "passkey": "[%key:component::bsblan::config::step::user::data_description::passkey%]", + "password": "[%key:component::bsblan::config::step::user::data_description::password%]", + "port": "[%key:component::bsblan::config::step::user::data_description::port%]", + "username": "[%key:component::bsblan::config::step::user::data_description::username%]" + }, + "description": "Update connection settings for your BSB-LAN device.", + "title": "Reconfigure BSB-LAN" + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", @@ -48,24 +68,32 @@ "username": "[%key:common::config_flow::data::username%]" }, "data_description": { - "host": "The hostname or IP address of your BSB-Lan device.", - "passkey": "The passkey for your BSB-Lan device.", - "password": "The password for your BSB-Lan device.", - "port": "The port number of your BSB-Lan device.", - "username": "The username for your BSB-Lan device." + "host": "The hostname or IP address of your BSB-LAN device.", + "passkey": "The passkey for your BSB-LAN device.", + "password": "The password for your BSB-LAN device.", + "port": "The port number of your BSB-LAN device.", + "username": "The username for your BSB-LAN device." }, - "description": "Set up your BSB-Lan device to integrate with Home Assistant.", - "title": "Connect to the BSB-Lan device" + "description": "Set up your BSB-LAN device to integrate with Home Assistant.", + "title": "Connect to the BSB-LAN device" } } }, "entity": { + "button": { + "sync_time": { + "name": "Sync time" + } + }, "sensor": { "current_temperature": { "name": "Current temperature" }, "outside_temperature": { "name": "Outside temperature" + }, + "total_energy": { + "name": "Total energy" } } }, @@ -73,6 +101,12 @@ "config_entry_not_loaded": { "message": "The device `{device_name}` is not currently loaded or available" }, + "coordinator_auth_error": { + "message": "Authentication failed for BSB-LAN device" + }, + "coordinator_connection_error": { + "message": "Error while establishing connection with BSB-LAN device at {host}" + }, "end_time_before_start_time": { "message": "End time ({end_time}) must be after start time ({start_time})" }, @@ -83,14 +117,11 @@ "message": "No configuration entry found for device: {device_id}" }, "set_data_error": { - "message": "An error occurred while sending the data to the BSB-Lan device" + "message": "An error occurred while sending the data to the BSB-LAN device" }, "set_operation_mode_error": { "message": "An error occurred while setting the operation mode" }, - "set_preset_mode_error": { - "message": "Can't set preset mode to {preset_mode} when HVAC mode is not set to auto" - }, "set_schedule_failed": { "message": "Failed to set hot water schedule: {error}" }, @@ -101,7 +132,7 @@ "message": "Authentication failed while retrieving static device data" }, "setup_connection_error": { - "message": "Failed to retrieve static device data from BSB-Lan device at {host}" + "message": "Failed to retrieve static device data from BSB-LAN device at {host}" }, "setup_general_error": { "message": "An unknown error occurred while retrieving static device data" @@ -150,7 +181,7 @@ "name": "Set hot water schedule" }, "sync_time": { - "description": "Synchronize Home Assistant time to the BSB-Lan device. Only updates if device time differs from Home Assistant time.", + "description": "Synchronize Home Assistant time to the BSB-LAN device. Only updates if device time differs from Home Assistant time.", "fields": { "device_id": { "description": "The BSB-LAN device to sync time for.", diff --git a/homeassistant/components/bsblan/water_heater.py b/homeassistant/components/bsblan/water_heater.py index 4220b33534b6aa..ec8d01b9c710df 100644 --- a/homeassistant/components/bsblan/water_heater.py +++ b/homeassistant/components/bsblan/water_heater.py @@ -63,6 +63,7 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity): """Defines a BSBLAN water heater entity.""" _attr_name = None + _attr_operation_list = list(HA_TO_BSBLAN_OPERATION_MODE.keys()) _attr_supported_features = ( WaterHeaterEntityFeature.TARGET_TEMPERATURE | WaterHeaterEntityFeature.OPERATION_MODE @@ -73,7 +74,6 @@ def __init__(self, data: BSBLanData) -> None: """Initialize BSBLAN water heater.""" super().__init__(data.fast_coordinator, data.slow_coordinator, data) self._attr_unique_id = format_mac(data.device.MAC) - self._attr_operation_list = list(HA_TO_BSBLAN_OPERATION_MODE.keys()) # Set temperature unit self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit @@ -81,58 +81,56 @@ def __init__(self, data: BSBLanData) -> None: self._attr_available = True # Set temperature limits based on device capabilities from slow coordinator + dhw_config = ( + data.slow_coordinator.data.dhw_config + if data.slow_coordinator.data + else None + ) + # For min_temp: Use reduced_setpoint from config data (slow polling) if ( - data.slow_coordinator.data - and data.slow_coordinator.data.dhw_config is not None - and data.slow_coordinator.data.dhw_config.reduced_setpoint is not None - and hasattr(data.slow_coordinator.data.dhw_config.reduced_setpoint, "value") + dhw_config is not None + and dhw_config.reduced_setpoint is not None + and dhw_config.reduced_setpoint.value is not None ): - self._attr_min_temp = float( - data.slow_coordinator.data.dhw_config.reduced_setpoint.value - ) + self._attr_min_temp = dhw_config.reduced_setpoint.value else: self._attr_min_temp = 10.0 # Default minimum # For max_temp: Use nominal_setpoint_max from config data (slow polling) if ( - data.slow_coordinator.data - and data.slow_coordinator.data.dhw_config is not None - and data.slow_coordinator.data.dhw_config.nominal_setpoint_max is not None - and hasattr( - data.slow_coordinator.data.dhw_config.nominal_setpoint_max, "value" - ) + dhw_config is not None + and dhw_config.nominal_setpoint_max is not None + and dhw_config.nominal_setpoint_max.value is not None ): - self._attr_max_temp = float( - data.slow_coordinator.data.dhw_config.nominal_setpoint_max.value - ) + self._attr_max_temp = dhw_config.nominal_setpoint_max.value else: self._attr_max_temp = 65.0 # Default maximum @property def current_operation(self) -> str | None: """Return current operation.""" - if self.coordinator.data.dhw.operating_mode is None: + if ( + operating_mode := self.coordinator.data.dhw.operating_mode + ) is None or operating_mode.value is None: return None - # The operating_mode.value is an integer (0=Off, 1=On, 2=Eco) - current_mode_value = self.coordinator.data.dhw.operating_mode.value - if isinstance(current_mode_value, int): - return BSBLAN_TO_HA_OPERATION_MODE.get(current_mode_value) - return None + return BSBLAN_TO_HA_OPERATION_MODE.get(operating_mode.value) @property def current_temperature(self) -> float | None: """Return the current temperature.""" - if self.coordinator.data.dhw.dhw_actual_value_top_temperature is None: + if ( + current_temp := self.coordinator.data.dhw.dhw_actual_value_top_temperature + ) is None: return None - return self.coordinator.data.dhw.dhw_actual_value_top_temperature.value + return current_temp.value @property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" - if self.coordinator.data.dhw.nominal_setpoint is None: + if (target_temp := self.coordinator.data.dhw.nominal_setpoint) is None: return None - return self.coordinator.data.dhw.nominal_setpoint.value + return target_temp.value async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" diff --git a/homeassistant/components/button/trigger.py b/homeassistant/components/button/trigger.py index 5b9e2904dd14c0..ea69b06b5115be 100644 --- a/homeassistant/components/button/trigger.py +++ b/homeassistant/components/button/trigger.py @@ -2,6 +2,7 @@ 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, @@ -14,7 +15,7 @@ class ButtonPressedTrigger(EntityTriggerBase): """Trigger for button entity presses.""" - _domain = DOMAIN + _domain_specs = {DOMAIN: DomainSpec()} _schema = ENTITY_STATE_TRIGGER_SCHEMA def is_valid_transition(self, from_state: State, to_state: State) -> bool: diff --git a/homeassistant/components/cambridge_audio/__init__.py b/homeassistant/components/cambridge_audio/__init__.py index 8b910bb81bba9b..cdae1a6dc0c813 100644 --- a/homeassistant/components/cambridge_audio/__init__.py +++ b/homeassistant/components/cambridge_audio/__init__.py @@ -16,7 +16,12 @@ from .const import CONNECT_TIMEOUT, DOMAIN, STREAM_MAGIC_EXCEPTIONS -PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.SELECT, Platform.SWITCH] +PLATFORMS: list[Platform] = [ + Platform.MEDIA_PLAYER, + Platform.NUMBER, + Platform.SELECT, + Platform.SWITCH, +] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/cambridge_audio/icons.json b/homeassistant/components/cambridge_audio/icons.json index dbeb52a73ff17f..a0acb5f0fd983b 100644 --- a/homeassistant/components/cambridge_audio/icons.json +++ b/homeassistant/components/cambridge_audio/icons.json @@ -1,5 +1,10 @@ { "entity": { + "number": { + "room_correction_intensity": { + "default": "mdi:home-sound-out" + } + }, "select": { "audio_output": { "default": "mdi:audio-input-stereo-minijack" @@ -24,6 +29,12 @@ "early_update": { "default": "mdi:update" }, + "equalizer": { + "default": "mdi:equalizer", + "state": { + "off": "mdi:equalizer-outline" + } + }, "pre_amp": { "default": "mdi:volume-high", "state": { diff --git a/homeassistant/components/cambridge_audio/manifest.json b/homeassistant/components/cambridge_audio/manifest.json index 445cd2fc60b97f..06a1bcb0bc38a0 100644 --- a/homeassistant/components/cambridge_audio/manifest.json +++ b/homeassistant/components/cambridge_audio/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_push", "loggers": ["aiostreammagic"], "quality_scale": "platinum", - "requirements": ["aiostreammagic==2.12.1"], + "requirements": ["aiostreammagic==2.13.0"], "zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."] } diff --git a/homeassistant/components/cambridge_audio/media_browser.py b/homeassistant/components/cambridge_audio/media_browser.py index efe55ee792e443..a9fa28bd554163 100644 --- a/homeassistant/components/cambridge_audio/media_browser.py +++ b/homeassistant/components/cambridge_audio/media_browser.py @@ -38,7 +38,7 @@ async def _root_payload( media_class=MediaClass.DIRECTORY, media_content_id="", media_content_type="presets", - thumbnail="https://brands.home-assistant.io/_/cambridge_audio/logo.png", + thumbnail="/api/brands/integration/cambridge_audio/logo.png", can_play=False, can_expand=True, ) diff --git a/homeassistant/components/cambridge_audio/number.py b/homeassistant/components/cambridge_audio/number.py new file mode 100644 index 00000000000000..87e64a4df67f70 --- /dev/null +++ b/homeassistant/components/cambridge_audio/number.py @@ -0,0 +1,88 @@ +"""Support for Cambridge Audio number entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from aiostreammagic import StreamMagicClient + +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 . import CambridgeAudioConfigEntry +from .entity import CambridgeAudioEntity, command + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class CambridgeAudioNumberEntityDescription(NumberEntityDescription): + """Describes Cambridge Audio number entity.""" + + exists_fn: Callable[[StreamMagicClient], bool] = lambda _: True + value_fn: Callable[[StreamMagicClient], int] + set_value_fn: Callable[[StreamMagicClient, int], Awaitable[None]] + + +def room_correction_intensity(client: StreamMagicClient) -> int: + """Get room correction intensity.""" + if TYPE_CHECKING: + assert client.audio.tilt_eq is not None + return client.audio.tilt_eq.intensity + + +CONTROL_ENTITIES: tuple[CambridgeAudioNumberEntityDescription, ...] = ( + CambridgeAudioNumberEntityDescription( + key="room_correction_intensity", + translation_key="room_correction_intensity", + entity_category=EntityCategory.CONFIG, + native_min_value=-15, + native_max_value=15, + native_step=1, + exists_fn=lambda client: client.audio.tilt_eq is not None, + value_fn=room_correction_intensity, + set_value_fn=lambda client, value: client.set_room_correction_intensity(value), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CambridgeAudioConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Cambridge Audio number entities based on a config entry.""" + client = entry.runtime_data + async_add_entities( + CambridgeAudioNumber(entry.runtime_data, description) + for description in CONTROL_ENTITIES + if description.exists_fn(client) + ) + + +class CambridgeAudioNumber(CambridgeAudioEntity, NumberEntity): + """Defines a Cambridge Audio number entity.""" + + entity_description: CambridgeAudioNumberEntityDescription + + def __init__( + self, + client: StreamMagicClient, + description: CambridgeAudioNumberEntityDescription, + ) -> None: + """Initialize Cambridge Audio number entity.""" + super().__init__(client) + self.entity_description = description + self._attr_unique_id = f"{client.info.unit_id}-{description.key}" + + @property + def native_value(self) -> int | None: + """Return the state of the number.""" + return self.entity_description.value_fn(self.client) + + @command + async def async_set_native_value(self, value: float) -> None: + """Set the selected value.""" + await self.entity_description.set_value_fn(self.client, int(value)) diff --git a/homeassistant/components/cambridge_audio/strings.json b/homeassistant/components/cambridge_audio/strings.json index c386df6bf479f3..a5d2e83e526650 100644 --- a/homeassistant/components/cambridge_audio/strings.json +++ b/homeassistant/components/cambridge_audio/strings.json @@ -35,6 +35,11 @@ } }, "entity": { + "number": { + "room_correction_intensity": { + "name": "Room correction intensity" + } + }, "select": { "audio_output": { "name": "Audio output" @@ -60,6 +65,9 @@ "early_update": { "name": "Early update" }, + "equalizer": { + "name": "Equalizer" + }, "pre_amp": { "name": "Pre-Amp" }, diff --git a/homeassistant/components/cambridge_audio/switch.py b/homeassistant/components/cambridge_audio/switch.py index be521aad4f3510..43a1ebf1533a7e 100644 --- a/homeassistant/components/cambridge_audio/switch.py +++ b/homeassistant/components/cambridge_audio/switch.py @@ -33,6 +33,13 @@ def room_correction_enabled(client: StreamMagicClient) -> bool: return client.audio.tilt_eq.enabled +def equalizer_enabled(client: StreamMagicClient) -> bool: + """Check if equalizer is enabled.""" + if TYPE_CHECKING: + assert client.audio.user_eq is not None + return client.audio.user_eq.enabled + + CONTROL_ENTITIES: tuple[CambridgeAudioSwitchEntityDescription, ...] = ( CambridgeAudioSwitchEntityDescription( key="pre_amp", @@ -56,6 +63,14 @@ def room_correction_enabled(client: StreamMagicClient) -> bool: value_fn=room_correction_enabled, set_value_fn=lambda client, value: client.set_room_correction_mode(value), ), + CambridgeAudioSwitchEntityDescription( + key="equalizer", + translation_key="equalizer", + entity_category=EntityCategory.CONFIG, + load_fn=lambda client: client.audio.user_eq is not None, + value_fn=equalizer_enabled, + set_value_fn=lambda client, value: client.set_equalizer_mode(value), + ), ) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 9362faa1093df4..16dd4432ecc32d 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -27,7 +27,7 @@ from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, - DOMAIN as DOMAIN_MP, + DOMAIN as MP_DOMAIN, SERVICE_PLAY_MEDIA, ) from homeassistant.components.stream import ( @@ -133,7 +133,7 @@ class CameraEntityFeature(IntFlag): CAMERA_SERVICE_SNAPSHOT: VolDictType = {vol.Required(ATTR_FILENAME): cv.template} CAMERA_SERVICE_PLAY_STREAM: VolDictType = { - vol.Required(ATTR_MEDIA_PLAYER): cv.entities_domain(DOMAIN_MP), + vol.Required(ATTR_MEDIA_PLAYER): cv.entities_domain(MP_DOMAIN), vol.Optional(ATTR_FORMAT, default="hls"): vol.In(OUTPUT_FORMATS), } @@ -1044,7 +1044,7 @@ async def async_handle_play_stream_service( url = f"{get_url(hass)}{url}" await hass.services.async_call( - DOMAIN_MP, + MP_DOMAIN, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: service_call.data[ATTR_MEDIA_PLAYER], diff --git a/homeassistant/components/casper_glow/__init__.py b/homeassistant/components/casper_glow/__init__.py new file mode 100644 index 00000000000000..0c399517cf02d5 --- /dev/null +++ b/homeassistant/components/casper_glow/__init__.py @@ -0,0 +1,39 @@ +"""The Casper Glow integration.""" + +from __future__ import annotations + +from pycasperglow import CasperGlow + +from homeassistant.components import bluetooth +from homeassistant.const import CONF_ADDRESS, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator + +PLATFORMS: list[Platform] = [Platform.LIGHT] + + +async def async_setup_entry(hass: HomeAssistant, entry: CasperGlowConfigEntry) -> bool: + """Set up Casper Glow from a config entry.""" + address: str = entry.data[CONF_ADDRESS] + ble_device = bluetooth.async_ble_device_from_address(hass, address.upper(), True) + if not ble_device: + raise ConfigEntryNotReady( + f"Could not find Casper Glow device with address {address}" + ) + + glow = CasperGlow(ble_device) + coordinator = CasperGlowCoordinator(hass, glow, entry.title) + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(coordinator.async_start()) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: CasperGlowConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/casper_glow/config_flow.py b/homeassistant/components/casper_glow/config_flow.py new file mode 100644 index 00000000000000..ee8afe3d3cb388 --- /dev/null +++ b/homeassistant/components/casper_glow/config_flow.py @@ -0,0 +1,151 @@ +"""Config flow for Casper Glow integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from bluetooth_data_tools import human_readable_name +from pycasperglow import CasperGlow, CasperGlowError +import voluptuous as vol + +from homeassistant.components.bluetooth import ( + BluetoothServiceInfoBleak, + async_discovered_service_info, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ADDRESS +from homeassistant.helpers.device_registry import format_mac + +from .const import DOMAIN, LOCAL_NAMES + +_LOGGER = logging.getLogger(__name__) + + +class CasperGlowConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Casper Glow.""" + + VERSION = 1 + MINOR_VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovery_info: BluetoothServiceInfoBleak | None = None + self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {} + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfoBleak + ) -> ConfigFlowResult: + """Handle the bluetooth discovery step.""" + await self.async_set_unique_id(format_mac(discovery_info.address)) + self._abort_if_unique_id_configured() + self._discovery_info = discovery_info + self.context["title_placeholders"] = { + "name": human_readable_name( + None, discovery_info.name, discovery_info.address + ) + } + return await self.async_step_bluetooth_confirm() + + async def async_step_bluetooth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm a discovered Casper Glow device.""" + assert self._discovery_info is not None + if user_input is not None: + return self.async_create_entry( + title=self.context["title_placeholders"]["name"], + data={CONF_ADDRESS: self._discovery_info.address}, + ) + glow = CasperGlow(self._discovery_info.device) + try: + await glow.handshake() + except CasperGlowError: + return self.async_abort(reason="cannot_connect") + except Exception: + _LOGGER.exception( + "Unexpected error during Casper Glow config flow " + "(step=bluetooth_confirm, address=%s)", + self._discovery_info.address, + ) + return self.async_abort(reason="unknown") + self._set_confirm_only() + return self.async_show_form( + step_id="bluetooth_confirm", + description_placeholders=self.context["title_placeholders"], + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step to pick discovered device.""" + errors: dict[str, str] = {} + + if user_input is not None: + address = user_input[CONF_ADDRESS] + discovery_info = self._discovered_devices[address] + await self.async_set_unique_id( + format_mac(discovery_info.address), raise_on_progress=False + ) + self._abort_if_unique_id_configured() + glow = CasperGlow(discovery_info.device) + try: + await glow.handshake() + except CasperGlowError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception( + "Unexpected error during Casper Glow config flow " + "(step=user, address=%s)", + discovery_info.address, + ) + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=human_readable_name( + None, discovery_info.name, discovery_info.address + ), + data={ + CONF_ADDRESS: discovery_info.address, + }, + ) + + if discovery := self._discovery_info: + self._discovered_devices[discovery.address] = discovery + else: + current_addresses = self._async_current_ids(include_ignore=False) + for discovery in async_discovered_service_info(self.hass): + if ( + format_mac(discovery.address) in current_addresses + or discovery.address in self._discovered_devices + or not ( + discovery.name + and any( + discovery.name.startswith(local_name) + for local_name in LOCAL_NAMES + ) + ) + ): + continue + self._discovered_devices[discovery.address] = discovery + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + data_schema = vol.Schema( + { + vol.Required(CONF_ADDRESS): vol.In( + { + service_info.address: human_readable_name( + None, service_info.name, service_info.address + ) + for service_info in self._discovered_devices.values() + } + ), + } + ) + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + ) diff --git a/homeassistant/components/casper_glow/const.py b/homeassistant/components/casper_glow/const.py new file mode 100644 index 00000000000000..37b5b7656ff249 --- /dev/null +++ b/homeassistant/components/casper_glow/const.py @@ -0,0 +1,16 @@ +"""Constants for the Casper Glow integration.""" + +from datetime import timedelta + +from pycasperglow import BRIGHTNESS_LEVELS, DEVICE_NAME_PREFIX, DIMMING_TIME_MINUTES + +DOMAIN = "casper_glow" + +LOCAL_NAMES = {DEVICE_NAME_PREFIX} + +SORTED_BRIGHTNESS_LEVELS = sorted(BRIGHTNESS_LEVELS) + +DEFAULT_DIMMING_TIME_MINUTES: int = DIMMING_TIME_MINUTES[0] + +# 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 new file mode 100644 index 00000000000000..6b363d0445bacc --- /dev/null +++ b/homeassistant/components/casper_glow/coordinator.py @@ -0,0 +1,103 @@ +"""Coordinator for the Casper Glow integration.""" + +from __future__ import annotations + +import logging + +from bleak import BleakError +from bluetooth_data_tools import monotonic_time_coarse +from pycasperglow import CasperGlow + +from homeassistant.components.bluetooth import ( + BluetoothChange, + BluetoothScanningMode, + BluetoothServiceInfoBleak, +) +from homeassistant.components.bluetooth.active_update_coordinator import ( + ActiveBluetoothDataUpdateCoordinator, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback + +from .const import STATE_POLL_INTERVAL + +_LOGGER = logging.getLogger(__name__) + +type CasperGlowConfigEntry = ConfigEntry[CasperGlowCoordinator] + + +class CasperGlowCoordinator(ActiveBluetoothDataUpdateCoordinator[None]): + """Coordinator for Casper Glow BLE devices.""" + + def __init__( + self, + hass: HomeAssistant, + device: CasperGlow, + title: str, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass=hass, + logger=_LOGGER, + address=device.address, + mode=BluetoothScanningMode.PASSIVE, + needs_poll_method=self._needs_poll, + poll_method=self._async_update, + connectable=True, + ) + self.device = device + self.last_dimming_time_minutes: int | None = ( + device.state.configured_dimming_time_minutes + ) + self.title = title + + @callback + def _needs_poll( + self, + service_info: BluetoothServiceInfoBleak, + seconds_since_last_poll: float | None, + ) -> bool: + """Return True if a poll is needed.""" + return ( + seconds_since_last_poll is None + or seconds_since_last_poll >= STATE_POLL_INTERVAL.total_seconds() + ) + + async def _async_update(self, service_info: BluetoothServiceInfoBleak) -> None: + """Poll device state.""" + await self.device.query_state() + + async def _async_poll(self) -> None: + """Poll the device and log availability changes.""" + assert self._last_service_info + + try: + await self._async_poll_data(self._last_service_info) + except BleakError as exc: + if self.last_poll_successful: + _LOGGER.info("%s is unavailable: %s", self.title, exc) + self.last_poll_successful = False + return + except Exception: + if self.last_poll_successful: + _LOGGER.exception("%s: unexpected error while polling", self.title) + self.last_poll_successful = False + return + finally: + self._last_poll = monotonic_time_coarse() + + if not self.last_poll_successful: + _LOGGER.info("%s is back online", self.title) + self.last_poll_successful = True + + self._async_handle_bluetooth_poll() + + @callback + def _async_handle_bluetooth_event( + self, + service_info: BluetoothServiceInfoBleak, + change: BluetoothChange, + ) -> None: + """Update BLE device reference on each advertisement.""" + self.device.set_ble_device(service_info.device) + super()._async_handle_bluetooth_event(service_info, change) diff --git a/homeassistant/components/casper_glow/entity.py b/homeassistant/components/casper_glow/entity.py new file mode 100644 index 00000000000000..d7df3714f4eda2 --- /dev/null +++ b/homeassistant/components/casper_glow/entity.py @@ -0,0 +1,47 @@ +"""Base entity for the Casper Glow integration.""" + +from __future__ import annotations + +from collections.abc import Awaitable + +from pycasperglow import CasperGlowError + +from homeassistant.components.bluetooth.passive_update_coordinator import ( + PassiveBluetoothCoordinatorEntity, +) +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo, format_mac + +from .const import DOMAIN +from .coordinator import CasperGlowCoordinator + + +class CasperGlowEntity(PassiveBluetoothCoordinatorEntity[CasperGlowCoordinator]): + """Base class for Casper Glow entities.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: CasperGlowCoordinator) -> None: + """Initialize a Casper Glow entity.""" + super().__init__(coordinator) + self._device = coordinator.device + self._attr_device_info = DeviceInfo( + manufacturer="Casper", + model="Glow", + model_id="G01", + connections={ + (dr.CONNECTION_BLUETOOTH, format_mac(coordinator.device.address)) + }, + ) + + async def _async_command(self, coro: Awaitable[None]) -> None: + """Execute a device command.""" + try: + await coro + except CasperGlowError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={"error": str(err)}, + ) from err diff --git a/homeassistant/components/casper_glow/light.py b/homeassistant/components/casper_glow/light.py new file mode 100644 index 00000000000000..a8e29b2a7a3c24 --- /dev/null +++ b/homeassistant/components/casper_glow/light.py @@ -0,0 +1,104 @@ +"""Casper Glow integration light platform.""" + +from __future__ import annotations + +from typing import Any + +from pycasperglow import GlowState + +from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, +) + +from .const import DEFAULT_DIMMING_TIME_MINUTES, SORTED_BRIGHTNESS_LEVELS +from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator +from .entity import CasperGlowEntity + +PARALLEL_UPDATES = 1 + + +def _ha_brightness_to_device_pct(brightness: int) -> int: + """Convert HA brightness (1-255) to device percentage by snapping to nearest.""" + return percentage_to_ordered_list_item( + SORTED_BRIGHTNESS_LEVELS, round(brightness * 100 / 255) + ) + + +def _device_pct_to_ha_brightness(pct: int) -> int: + """Convert device brightness percentage (60-100) to HA brightness (1-255).""" + percent = ordered_list_item_to_percentage(SORTED_BRIGHTNESS_LEVELS, pct) + return round(percent * 255 / 100) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CasperGlowConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the light platform for Casper Glow.""" + async_add_entities([CasperGlowLight(entry.runtime_data)]) + + +class CasperGlowLight(CasperGlowEntity, LightEntity): + """Representation of a Casper Glow light.""" + + _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + _attr_name = None + + def __init__(self, coordinator: CasperGlowCoordinator) -> None: + """Initialize a Casper Glow light.""" + super().__init__(coordinator) + self._attr_unique_id = format_mac(coordinator.device.address) + self._update_from_state(coordinator.device.state) + + 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 _update_from_state(self, state: GlowState) -> None: + """Update entity attributes from device state.""" + if state.is_on is not None: + self._attr_is_on = state.is_on + self._attr_color_mode = ColorMode.BRIGHTNESS + if state.brightness_level is not None: + self._attr_brightness = _device_pct_to_ha_brightness(state.brightness_level) + + @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() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on.""" + brightness_pct: int | None = None + if ATTR_BRIGHTNESS in kwargs: + brightness_pct = _ha_brightness_to_device_pct(kwargs[ATTR_BRIGHTNESS]) + + await self._async_command(self._device.turn_on()) + self._attr_is_on = True + self._attr_color_mode = ColorMode.BRIGHTNESS + if brightness_pct is not None: + await self._async_command( + self._device.set_brightness_and_dimming_time( + brightness_pct, + self.coordinator.last_dimming_time_minutes + if self.coordinator.last_dimming_time_minutes is not None + else DEFAULT_DIMMING_TIME_MINUTES, + ) + ) + self._attr_brightness = _device_pct_to_ha_brightness(brightness_pct) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + await self._async_command(self._device.turn_off()) + self._attr_is_on = False diff --git a/homeassistant/components/casper_glow/manifest.json b/homeassistant/components/casper_glow/manifest.json new file mode 100644 index 00000000000000..b883e7372e21e9 --- /dev/null +++ b/homeassistant/components/casper_glow/manifest.json @@ -0,0 +1,19 @@ +{ + "domain": "casper_glow", + "name": "Casper Glow", + "bluetooth": [ + { + "connectable": true, + "local_name": "Jar*" + } + ], + "codeowners": ["@mikeodr"], + "config_flow": true, + "dependencies": ["bluetooth_adapters"], + "documentation": "https://www.home-assistant.io/integrations/casper_glow", + "integration_type": "device", + "iot_class": "local_polling", + "loggers": ["pycasperglow"], + "quality_scale": "bronze", + "requirements": ["pycasperglow==1.1.0"] +} diff --git a/homeassistant/components/casper_glow/quality_scale.yaml b/homeassistant/components/casper_glow/quality_scale.yaml new file mode 100644 index 00000000000000..e6ec68c6764883 --- /dev/null +++ b/homeassistant/components/casper_glow/quality_scale.yaml @@ -0,0 +1,74 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No custom services. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No custom actions/services. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: No network discovery. + 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: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: + status: exempt + comment: No entity translations needed. + exception-translations: + status: exempt + comment: No custom services that raise exceptions. + icon-translations: + status: exempt + comment: No icon translations needed. + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: No web session is used by this integration. + strict-typing: done diff --git a/homeassistant/components/casper_glow/strings.json b/homeassistant/components/casper_glow/strings.json new file mode 100644 index 00000000000000..e4000c433c57e7 --- /dev/null +++ b/homeassistant/components/casper_glow/strings.json @@ -0,0 +1,34 @@ +{ + "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%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Do you want to set up {name}?" + }, + "user": { + "data": { + "address": "Bluetooth address" + }, + "data_description": { + "address": "The Bluetooth address of the Casper Glow light" + } + } + } + }, + "exceptions": { + "communication_error": { + "message": "An error occurred while communicating with the Casper Glow: {error}" + } + } +} diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index 4d2749dfc1158d..5d7c1a1a99cec7 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -15,7 +15,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["casttube", "pychromecast"], - "requirements": ["PyChromecast==14.0.9"], + "requirements": ["PyChromecast==14.0.10"], "single_config_entry": true, "zeroconf": ["_googlecast._tcp.local."] } diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 5d6f89586bf38e..6acbb068953ec0 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -804,9 +804,24 @@ def _media_status(self): @property def state(self) -> MediaPlayerState | None: """Return the state of the player.""" - # The lovelace app loops media to prevent timing out, don't show that + if (chromecast := self._chromecast) is None or ( + cast_status := self.cast_status + ) is None: + # Not connected to any chromecast, or not yet got any status + return None + + if ( + chromecast.cast_type == pychromecast.const.CAST_TYPE_CHROMECAST + and not chromecast.ignore_cec + and cast_status.is_active_input is False + ): + # The display interface for the device has been turned off or switched away + return MediaPlayerState.OFF + if self.app_id == CAST_APP_ID_HOMEASSISTANT_LOVELACE: + # The lovelace app loops media to prevent timing out, don't show that return MediaPlayerState.PLAYING + if (media_status := self._media_status()[0]) is not None: if media_status.player_state == MEDIA_PLAYER_STATE_PLAYING: return MediaPlayerState.PLAYING @@ -817,20 +832,16 @@ def state(self) -> MediaPlayerState | None: if media_status.player_is_idle: return MediaPlayerState.IDLE - if self._chromecast is not None and self._chromecast.is_idle: - # If library consider us idle, that is our off state - # it takes HDMI status into account for cast devices. - return MediaPlayerState.OFF - if self.app_id in APP_IDS_UNRELIABLE_MEDIA_INFO: # Some apps don't report media status, show the player as playing return MediaPlayerState.PLAYING - if self.app_id is not None: - # We have an active app - return MediaPlayerState.IDLE + if self.app_id in (pychromecast.IDLE_APP_ID, None): + # We have no active app or the home screen app. This is + # same app as APP_BACKDROP. + return MediaPlayerState.OFF - return None + return MediaPlayerState.IDLE @property def media_content_id(self) -> str | None: diff --git a/homeassistant/components/chess_com/__init__.py b/homeassistant/components/chess_com/__init__.py new file mode 100644 index 00000000000000..998bd942ec448a --- /dev/null +++ b/homeassistant/components/chess_com/__init__.py @@ -0,0 +1,31 @@ +"""The Chess.com integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import ChessConfigEntry, ChessCoordinator + +_PLATFORMS: list[Platform] = [ + Platform.SENSOR, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: ChessConfigEntry) -> bool: + """Set up Chess.com from a config entry.""" + + coordinator = ChessCoordinator(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: ChessConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/chess_com/config_flow.py b/homeassistant/components/chess_com/config_flow.py new file mode 100644 index 00000000000000..687d331b1ddb65 --- /dev/null +++ b/homeassistant/components/chess_com/config_flow.py @@ -0,0 +1,47 @@ +"""Config flow for the Chess.com integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from chess_com_api import ChessComClient, NotFoundError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_USERNAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ChessConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Chess.com.""" + + 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: + session = async_get_clientsession(self.hass) + client = ChessComClient(session=session) + try: + user = await client.get_player(user_input[CONF_USERNAME]) + except NotFoundError: + errors["base"] = "player_not_found" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(str(user.player_id)) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=user.name, data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_USERNAME): str}), + errors=errors, + ) diff --git a/homeassistant/components/chess_com/const.py b/homeassistant/components/chess_com/const.py new file mode 100644 index 00000000000000..37306161f00924 --- /dev/null +++ b/homeassistant/components/chess_com/const.py @@ -0,0 +1,3 @@ +"""Constants for the Chess.com integration.""" + +DOMAIN = "chess_com" diff --git a/homeassistant/components/chess_com/coordinator.py b/homeassistant/components/chess_com/coordinator.py new file mode 100644 index 00000000000000..666a4d127aaecd --- /dev/null +++ b/homeassistant/components/chess_com/coordinator.py @@ -0,0 +1,57 @@ +"""Coordinator for Chess.com.""" + +from dataclasses import dataclass +from datetime import timedelta +import logging + +from chess_com_api import ChessComAPIError, ChessComClient, Player, PlayerStats + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + +type ChessConfigEntry = ConfigEntry[ChessCoordinator] + + +@dataclass +class ChessData: + """Data for Chess.com.""" + + player: Player + stats: PlayerStats + + +class ChessCoordinator(DataUpdateCoordinator[ChessData]): + """Coordinator for Chess.com.""" + + config_entry: ChessConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: ChessConfigEntry, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=config_entry.title, + update_interval=timedelta(hours=1), + ) + self.client = ChessComClient(session=async_get_clientsession(hass)) + + async def _async_update_data(self) -> ChessData: + """Update data from Chess.com.""" + try: + player = await self.client.get_player(self.config_entry.data[CONF_USERNAME]) + stats = await self.client.get_player_stats( + self.config_entry.data[CONF_USERNAME] + ) + except ChessComAPIError as err: + raise UpdateFailed(f"Error communicating with Chess.com: {err}") from err + return ChessData(player=player, stats=stats) diff --git a/homeassistant/components/chess_com/diagnostics.py b/homeassistant/components/chess_com/diagnostics.py new file mode 100644 index 00000000000000..9df52a9834d408 --- /dev/null +++ b/homeassistant/components/chess_com/diagnostics.py @@ -0,0 +1,22 @@ +"""Diagnostics support for Chess.com.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from homeassistant.core import HomeAssistant + +from .coordinator import ChessConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ChessConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + + return { + "player": asdict(coordinator.data.player), + "stats": asdict(coordinator.data.stats), + } diff --git a/homeassistant/components/chess_com/entity.py b/homeassistant/components/chess_com/entity.py new file mode 100644 index 00000000000000..a0f49cbe30266c --- /dev/null +++ b/homeassistant/components/chess_com/entity.py @@ -0,0 +1,26 @@ +"""Base entity for Chess.com integration.""" + +from typing import TYPE_CHECKING + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ChessCoordinator + + +class ChessEntity(CoordinatorEntity[ChessCoordinator]): + """Base entity for Chess.com integration.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: ChessCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + if TYPE_CHECKING: + assert coordinator.config_entry.unique_id is not None + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.unique_id)}, + entry_type=DeviceEntryType.SERVICE, + manufacturer="Chess.com", + ) diff --git a/homeassistant/components/chess_com/icons.json b/homeassistant/components/chess_com/icons.json new file mode 100644 index 00000000000000..9b5e9291683bf1 --- /dev/null +++ b/homeassistant/components/chess_com/icons.json @@ -0,0 +1,21 @@ +{ + "entity": { + "sensor": { + "chess_daily_rating": { + "default": "mdi:chart-line" + }, + "followers": { + "default": "mdi:account-multiple" + }, + "total_daily_draw": { + "default": "mdi:chess-pawn" + }, + "total_daily_lost": { + "default": "mdi:chess-pawn" + }, + "total_daily_won": { + "default": "mdi:chess-pawn" + } + } + } +} diff --git a/homeassistant/components/chess_com/manifest.json b/homeassistant/components/chess_com/manifest.json new file mode 100644 index 00000000000000..067ba16dd1bde4 --- /dev/null +++ b/homeassistant/components/chess_com/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "chess_com", + "name": "Chess.com", + "codeowners": ["@joostlek"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/chess_com", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["chess_com_api"], + "quality_scale": "bronze", + "requirements": ["chess-com-api==1.1.0"] +} diff --git a/homeassistant/components/chess_com/quality_scale.yaml b/homeassistant/components/chess_com/quality_scale.yaml new file mode 100644 index 00000000000000..0fc58cdd824e86 --- /dev/null +++ b/homeassistant/components/chess_com/quality_scale.yaml @@ -0,0 +1,74 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: There are no 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: There are no custom actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Entities do not explicitly subscribe to events + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: There are no configuration parameters + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: Can't detect a game + discovery: + status: exempt + comment: Can't detect a game + 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: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: There are no repairable issues + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/chess_com/sensor.py b/homeassistant/components/chess_com/sensor.py new file mode 100644 index 00000000000000..3bb3ab268a4df9 --- /dev/null +++ b/homeassistant/components/chess_com/sensor.py @@ -0,0 +1,97 @@ +"""Sensor platform for Chess.com integration.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import ChessConfigEntry +from .coordinator import ChessCoordinator, ChessData +from .entity import ChessEntity + + +@dataclass(kw_only=True, frozen=True) +class ChessEntityDescription(SensorEntityDescription): + """Sensor description for Chess.com player.""" + + value_fn: Callable[[ChessData], float] + + +SENSORS: tuple[ChessEntityDescription, ...] = ( + ChessEntityDescription( + key="followers", + translation_key="followers", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda state: state.player.followers, + entity_registry_enabled_default=False, + ), + ChessEntityDescription( + key="chess_daily_rating", + translation_key="chess_daily_rating", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda state: state.stats.chess_daily["last"]["rating"], + ), + ChessEntityDescription( + key="total_daily_won", + translation_key="total_daily_won", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda state: state.stats.chess_daily["record"]["win"], + ), + ChessEntityDescription( + key="total_daily_lost", + translation_key="total_daily_lost", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda state: state.stats.chess_daily["record"]["loss"], + ), + ChessEntityDescription( + key="total_daily_draw", + translation_key="total_daily_draw", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda state: state.stats.chess_daily["record"]["draw"], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ChessConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Initialize the entries.""" + coordinator = entry.runtime_data + + async_add_entities( + ChessPlayerSensor(coordinator, description) for description in SENSORS + ) + + +class ChessPlayerSensor(ChessEntity, SensorEntity): + """Chess.com sensor.""" + + entity_description: ChessEntityDescription + + def __init__( + self, + coordinator: ChessCoordinator, + description: ChessEntityDescription, + ) -> None: + """Initialize the sensor.""" + 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: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/chess_com/strings.json b/homeassistant/components/chess_com/strings.json new file mode 100644 index 00000000000000..0646b004e794f4 --- /dev/null +++ b/homeassistant/components/chess_com/strings.json @@ -0,0 +1,47 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "error": { + "player_not_found": "Player not found.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "initiate_flow": { + "user": "Add player" + }, + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "username": "The Chess.com username of the player to monitor." + } + } + } + }, + "entity": { + "sensor": { + "chess_daily_rating": { + "name": "Daily chess rating" + }, + "followers": { + "name": "Followers", + "unit_of_measurement": "followers" + }, + "total_daily_draw": { + "name": "Total chess games drawn", + "unit_of_measurement": "[%key:component::chess_com::entity::sensor::total_daily_won::unit_of_measurement%]" + }, + "total_daily_lost": { + "name": "Total chess games lost", + "unit_of_measurement": "[%key:component::chess_com::entity::sensor::total_daily_won::unit_of_measurement%]" + }, + "total_daily_won": { + "name": "Total chess games won", + "unit_of_measurement": "games" + } + } + } +} diff --git a/homeassistant/components/clementine/media_player.py b/homeassistant/components/clementine/media_player.py index 04c1305cb13c1e..4554a9593881c9 100644 --- a/homeassistant/components/clementine/media_player.py +++ b/homeassistant/components/clementine/media_player.py @@ -66,6 +66,7 @@ class ClementineDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.PLAY ) + _attr_volume_step = 0.04 def __init__(self, client, name): """Initialize the Clementine device.""" @@ -124,16 +125,6 @@ async def async_get_media_image(self) -> tuple[bytes | None, str | None]: return None, None - def volume_up(self) -> None: - """Volume up the media player.""" - newvolume = min(self._client.volume + 4, 100) - self._client.set_volume(newvolume) - - def volume_down(self) -> None: - """Volume down media player.""" - newvolume = max(self._client.volume - 4, 0) - self._client.set_volume(newvolume) - def mute_volume(self, mute: bool) -> None: """Send mute command.""" self._client.set_volume(0) diff --git a/homeassistant/components/climate/condition.py b/homeassistant/components/climate/condition.py index e1cee4ede99624..8535890bd5ebf6 100644 --- a/homeassistant/components/climate/condition.py +++ b/homeassistant/components/climate/condition.py @@ -1,11 +1,8 @@ """Provides conditions for climates.""" from homeassistant.core import HomeAssistant -from homeassistant.helpers.condition import ( - Condition, - make_entity_state_attribute_condition, - make_entity_state_condition, -) +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.condition import Condition, make_entity_state_condition from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode @@ -22,14 +19,14 @@ HVACMode.HEAT_COOL, }, ), - "is_cooling": make_entity_state_attribute_condition( - DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING + "is_cooling": make_entity_state_condition( + {DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.COOLING ), - "is_drying": make_entity_state_attribute_condition( - DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING + "is_drying": make_entity_state_condition( + {DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING ), - "is_heating": make_entity_state_attribute_condition( - DOMAIN, ATTR_HVAC_ACTION, HVACAction.HEATING + "is_heating": make_entity_state_condition( + {DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING ), } diff --git a/homeassistant/components/climate/icons.json b/homeassistant/components/climate/icons.json index 35a9d7cbea9082..ebc8333cca2857 100644 --- a/homeassistant/components/climate/icons.json +++ b/homeassistant/components/climate/icons.json @@ -115,18 +115,6 @@ } }, "triggers": { - "current_humidity_changed": { - "trigger": "mdi:water-percent" - }, - "current_humidity_crossed_threshold": { - "trigger": "mdi:water-percent" - }, - "current_temperature_changed": { - "trigger": "mdi:thermometer" - }, - "current_temperature_crossed_threshold": { - "trigger": "mdi:thermometer" - }, "hvac_mode_changed": { "trigger": "mdi:thermostat" }, diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 06b9ef3407e29a..d7b3501deefd52 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -372,78 +372,6 @@ }, "title": "Climate", "triggers": { - "current_humidity_changed": { - "description": "Triggers after the humidity measured by one or more climate-control devices changes.", - "fields": { - "above": { - "description": "Trigger when the humidity is above this value.", - "name": "Above" - }, - "below": { - "description": "Trigger when the humidity is below this value.", - "name": "Below" - } - }, - "name": "Climate-control device current humidity changed" - }, - "current_humidity_crossed_threshold": { - "description": "Triggers after the humidity measured by one or more climate-control devices crosses a threshold.", - "fields": { - "behavior": { - "description": "[%key:component::climate::common::trigger_behavior_description%]", - "name": "[%key:component::climate::common::trigger_behavior_name%]" - }, - "lower_limit": { - "description": "Lower threshold limit.", - "name": "Lower threshold" - }, - "threshold_type": { - "description": "Type of threshold crossing to trigger on.", - "name": "Threshold type" - }, - "upper_limit": { - "description": "Upper threshold limit.", - "name": "Upper threshold" - } - }, - "name": "Climate-control device current humidity crossed threshold" - }, - "current_temperature_changed": { - "description": "Triggers after the temperature measured by one or more climate-control devices changes.", - "fields": { - "above": { - "description": "Trigger when the temperature is above this value.", - "name": "Above" - }, - "below": { - "description": "Trigger when the temperature is below this value.", - "name": "Below" - } - }, - "name": "Climate-control device current temperature changed" - }, - "current_temperature_crossed_threshold": { - "description": "Triggers after the temperature measured by one or more climate-control devices crosses a threshold.", - "fields": { - "behavior": { - "description": "[%key:component::climate::common::trigger_behavior_description%]", - "name": "[%key:component::climate::common::trigger_behavior_name%]" - }, - "lower_limit": { - "description": "Lower threshold limit.", - "name": "Lower threshold" - }, - "threshold_type": { - "description": "Type of threshold crossing to trigger on.", - "name": "Threshold type" - }, - "upper_limit": { - "description": "Upper threshold limit.", - "name": "Upper threshold" - } - }, - "name": "Climate-control device current temperature crossed threshold" - }, "hvac_mode_changed": { "description": "Triggers after the mode of one or more climate-control devices changes.", "fields": { diff --git a/homeassistant/components/climate/trigger.py b/homeassistant/components/climate/trigger.py index 10f40cad661900..cd32d2ceafd223 100644 --- a/homeassistant/components/climate/trigger.py +++ b/homeassistant/components/climate/trigger.py @@ -5,27 +5,19 @@ from homeassistant.const import ATTR_TEMPERATURE, CONF_OPTIONS from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec from homeassistant.helpers.trigger import ( ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST, EntityTargetStateTriggerBase, Trigger, TriggerConfig, - make_entity_numerical_state_attribute_changed_trigger, - make_entity_numerical_state_attribute_crossed_threshold_trigger, - make_entity_target_state_attribute_trigger, + make_entity_numerical_state_changed_trigger, + make_entity_numerical_state_crossed_threshold_trigger, make_entity_target_state_trigger, make_entity_transition_trigger, ) -from .const import ( - ATTR_CURRENT_HUMIDITY, - ATTR_CURRENT_TEMPERATURE, - ATTR_HUMIDITY, - ATTR_HVAC_ACTION, - DOMAIN, - HVACAction, - HVACMode, -) +from .const import ATTR_HUMIDITY, ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode CONF_HVAC_MODE = "hvac_mode" @@ -43,7 +35,7 @@ class HVACModeChangedTrigger(EntityTargetStateTriggerBase): """Trigger for entity state changes.""" - _domain = DOMAIN + _domain_specs = {DOMAIN: DomainSpec()} _schema = HVAC_MODE_CHANGED_TRIGGER_SCHEMA def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: @@ -53,36 +45,24 @@ def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: TRIGGERS: dict[str, type[Trigger]] = { - "current_humidity_changed": make_entity_numerical_state_attribute_changed_trigger( - DOMAIN, ATTR_CURRENT_HUMIDITY - ), - "current_humidity_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger( - DOMAIN, ATTR_CURRENT_HUMIDITY - ), - "current_temperature_changed": make_entity_numerical_state_attribute_changed_trigger( - DOMAIN, ATTR_CURRENT_TEMPERATURE - ), - "current_temperature_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger( - DOMAIN, ATTR_CURRENT_TEMPERATURE - ), "hvac_mode_changed": HVACModeChangedTrigger, - "started_cooling": make_entity_target_state_attribute_trigger( - DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING + "started_cooling": make_entity_target_state_trigger( + {DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.COOLING ), - "started_drying": make_entity_target_state_attribute_trigger( - DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING + "started_drying": make_entity_target_state_trigger( + {DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING ), - "target_humidity_changed": make_entity_numerical_state_attribute_changed_trigger( - DOMAIN, ATTR_HUMIDITY + "target_humidity_changed": make_entity_numerical_state_changed_trigger( + {DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)} ), - "target_humidity_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger( - DOMAIN, ATTR_HUMIDITY + "target_humidity_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( + {DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)} ), - "target_temperature_changed": make_entity_numerical_state_attribute_changed_trigger( - DOMAIN, ATTR_TEMPERATURE + "target_temperature_changed": make_entity_numerical_state_changed_trigger( + {DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)} ), - "target_temperature_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger( - DOMAIN, ATTR_TEMPERATURE + "target_temperature_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( + {DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)} ), "turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF), "turned_on": make_entity_transition_trigger( @@ -99,8 +79,8 @@ def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: HVACMode.HEAT_COOL, }, ), - "started_heating": make_entity_target_state_attribute_trigger( - DOMAIN, ATTR_HVAC_ACTION, HVACAction.HEATING + "started_heating": make_entity_target_state_trigger( + {DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING ), } diff --git a/homeassistant/components/climate/triggers.yaml b/homeassistant/components/climate/triggers.yaml index 6dc7c59b81a5bd..f5d02b5e9a3c34 100644 --- a/homeassistant/components/climate/triggers.yaml +++ b/homeassistant/components/climate/triggers.yaml @@ -66,20 +66,6 @@ hvac_mode_changed: - unknown multiple: true -current_humidity_changed: - target: *trigger_climate_target - fields: - above: *number_or_entity - below: *number_or_entity - -current_humidity_crossed_threshold: - target: *trigger_climate_target - fields: - behavior: *trigger_behavior - threshold_type: *trigger_threshold_type - lower_limit: *number_or_entity - upper_limit: *number_or_entity - target_humidity_changed: target: *trigger_climate_target fields: @@ -94,20 +80,6 @@ target_humidity_crossed_threshold: lower_limit: *number_or_entity upper_limit: *number_or_entity -current_temperature_changed: - target: *trigger_climate_target - fields: - above: *number_or_entity - below: *number_or_entity - -current_temperature_crossed_threshold: - target: *trigger_climate_target - fields: - behavior: *trigger_behavior - threshold_type: *trigger_threshold_type - lower_limit: *number_or_entity - upper_limit: *number_or_entity - target_temperature_changed: target: *trigger_climate_target fields: diff --git a/homeassistant/components/cloud/ai_task.py b/homeassistant/components/cloud/ai_task.py index a92060db7b14b0..7123b5cd32b9f5 100644 --- a/homeassistant/components/cloud/ai_task.py +++ b/homeassistant/components/cloud/ai_task.py @@ -31,6 +31,7 @@ def _convert_image_for_editing(data: bytes) -> tuple[bytes, str]: """Ensure the image data is in a format accepted by OpenAI image edits.""" + img: Image.Image stream = io.BytesIO(data) with Image.open(stream) as img: mode = img.mode diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index bca65a68abd780..180c14ef11173b 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -18,6 +18,7 @@ BackupAgent, BackupAgentError, BackupNotFound, + OnProgressCallback, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator @@ -106,6 +107,7 @@ async def async_upload_backup( *, open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], backup: AgentBackup, + on_progress: OnProgressCallback, **kwargs: Any, ) -> None: """Upload a backup. diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 5dafed419ee3e1..53ed41d5b6d816 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -516,6 +516,8 @@ async def _generate_markdown( hass_info: dict[str, Any], domains_info: dict[str, dict[str, str]], ) -> str: + cloud = hass.data[DATA_CLOUD] + def get_domain_table_markdown(domain_info: dict[str, Any]) -> str: if len(domain_info) == 0: return "No information available\n" @@ -572,6 +574,15 @@ def get_domain_table_markdown(domain_info: dict[str, Any]) -> str: "\n\n" ) + # Add stored latency response if available + if locations := cloud.remote.latency_by_location: + markdown += "## Latency by location\n\n" + markdown += "Location | Latency (ms)\n" + markdown += "--- | ---\n" + for location in sorted(locations): + markdown += f"{location} | {locations[location]['avg'] or 'N/A'}\n" + markdown += "\n" + # Add installed packages section try: installed_packages = await async_get_installed_packages() diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index e82fc715156c1a..c7993577a819ca 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==1.13.0", "openai==2.21.0"], + "requirements": ["hass-nabucasa==2.0.0", "openai==2.21.0"], "single_config_entry": true } diff --git a/homeassistant/components/cloudflare_r2/backup.py b/homeassistant/components/cloudflare_r2/backup.py index cef9294182e40b..4fc8199a4b34f4 100644 --- a/homeassistant/components/cloudflare_r2/backup.py +++ b/homeassistant/components/cloudflare_r2/backup.py @@ -14,6 +14,7 @@ BackupAgent, BackupAgentError, BackupNotFound, + OnProgressCallback, suggested_filename, ) from homeassistant.core import HomeAssistant, callback @@ -129,6 +130,7 @@ async def async_upload_backup( *, open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], backup: AgentBackup, + on_progress: OnProgressCallback, **kwargs: Any, ) -> None: """Upload a backup. diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 6f9fd390ea37d7..b5dbacdb66c468 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.0"] + "requirements": ["aiocomelit==2.0.1"] } diff --git a/homeassistant/components/compit/__init__.py b/homeassistant/components/compit/__init__.py index ef8596593545f7..0a0e7e6eabf135 100644 --- a/homeassistant/components/compit/__init__.py +++ b/homeassistant/components/compit/__init__.py @@ -10,9 +10,12 @@ from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator PLATFORMS = [ + Platform.BINARY_SENSOR, Platform.CLIMATE, + Platform.FAN, Platform.NUMBER, Platform.SELECT, + Platform.SENSOR, Platform.WATER_HEATER, ] diff --git a/homeassistant/components/compit/binary_sensor.py b/homeassistant/components/compit/binary_sensor.py new file mode 100644 index 00000000000000..884af3870e85cd --- /dev/null +++ b/homeassistant/components/compit/binary_sensor.py @@ -0,0 +1,189 @@ +"""Binary sensor platform for Compit integration.""" + +from dataclasses import dataclass + +from compit_inext_api.consts import CompitParameter + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER_NAME +from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator + +PARALLEL_UPDATES = 0 +NO_SENSOR = "no_sensor" +ON_STATES = ["on", "yes", "charging", "alert", "exceeded"] + +DESCRIPTIONS: dict[CompitParameter, BinarySensorEntityDescription] = { + CompitParameter.AIRING: BinarySensorEntityDescription( + key=CompitParameter.AIRING.value, + translation_key="airing", + device_class=BinarySensorDeviceClass.WINDOW, + entity_category=EntityCategory.DIAGNOSTIC, + ), + CompitParameter.BATTERY_CHARGE_STATUS: BinarySensorEntityDescription( + key=CompitParameter.BATTERY_CHARGE_STATUS.value, + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + entity_category=EntityCategory.DIAGNOSTIC, + ), + CompitParameter.CO2_ALERT: BinarySensorEntityDescription( + key=CompitParameter.CO2_ALERT.value, + translation_key="co2_alert", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + CompitParameter.CO2_LEVEL: BinarySensorEntityDescription( + key=CompitParameter.CO2_LEVEL.value, + translation_key="co2_level", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + CompitParameter.DUST_ALERT: BinarySensorEntityDescription( + key=CompitParameter.DUST_ALERT.value, + translation_key="dust_alert", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + CompitParameter.PUMP_STATUS: BinarySensorEntityDescription( + key=CompitParameter.PUMP_STATUS.value, + translation_key="pump_status", + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + ), + CompitParameter.TEMPERATURE_ALERT: BinarySensorEntityDescription( + key=CompitParameter.TEMPERATURE_ALERT.value, + translation_key="temperature_alert", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), +} + + +@dataclass(frozen=True, kw_only=True) +class CompitDeviceDescription: + """Class to describe a Compit device.""" + + name: str + parameters: dict[CompitParameter, BinarySensorEntityDescription] + + +DEVICE_DEFINITIONS: dict[int, CompitDeviceDescription] = { + 12: CompitDeviceDescription( + name="Nano Color", + parameters={ + CompitParameter.CO2_LEVEL: DESCRIPTIONS[CompitParameter.CO2_LEVEL], + }, + ), + 78: CompitDeviceDescription( + name="SPM - Nano Color 2", + parameters={ + CompitParameter.DUST_ALERT: DESCRIPTIONS[CompitParameter.DUST_ALERT], + CompitParameter.TEMPERATURE_ALERT: DESCRIPTIONS[ + CompitParameter.TEMPERATURE_ALERT + ], + CompitParameter.CO2_ALERT: DESCRIPTIONS[CompitParameter.CO2_ALERT], + }, + ), + 223: CompitDeviceDescription( + name="Nano Color 2", + parameters={ + CompitParameter.AIRING: DESCRIPTIONS[CompitParameter.AIRING], + CompitParameter.CO2_LEVEL: DESCRIPTIONS[CompitParameter.CO2_LEVEL], + }, + ), + 225: CompitDeviceDescription( + name="SPM - Nano Color", + parameters={ + CompitParameter.CO2_LEVEL: DESCRIPTIONS[CompitParameter.CO2_LEVEL], + }, + ), + 226: CompitDeviceDescription( + name="AF-1", + parameters={ + CompitParameter.BATTERY_CHARGE_STATUS: DESCRIPTIONS[ + CompitParameter.BATTERY_CHARGE_STATUS + ], + CompitParameter.PUMP_STATUS: DESCRIPTIONS[CompitParameter.PUMP_STATUS], + }, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CompitConfigEntry, + async_add_devices: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Compit binary sensor entities from a config entry.""" + + coordinator = entry.runtime_data + async_add_devices( + CompitBinarySensor( + coordinator, + device_id, + device_definition.name, + code, + entity_description, + ) + for device_id, device in coordinator.connector.all_devices.items() + if (device_definition := DEVICE_DEFINITIONS.get(device.definition.code)) + for code, entity_description in device_definition.parameters.items() + if coordinator.connector.get_current_value(device_id, code) != NO_SENSOR + ) + + +class CompitBinarySensor( + CoordinatorEntity[CompitDataUpdateCoordinator], BinarySensorEntity +): + """Representation of a Compit binary sensor entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: CompitDataUpdateCoordinator, + device_id: int, + device_name: str, + parameter_code: CompitParameter, + entity_description: BinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor entity.""" + super().__init__(coordinator) + self.device_id = device_id + self.entity_description = entity_description + self._attr_unique_id = f"{device_id}_{entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(device_id))}, + name=device_name, + manufacturer=MANUFACTURER_NAME, + model=device_name, + ) + self.parameter_code = parameter_code + + @property + def available(self) -> bool: + """Return if entity is available.""" + return ( + super().available + and self.coordinator.connector.get_device(self.device_id) is not None + ) + + @property + def is_on(self) -> bool | None: + """Return the state of the binary sensor.""" + value = self.coordinator.connector.get_current_value( + self.device_id, self.parameter_code + ) + + if value is None: + return None + + return value in ON_STATES diff --git a/homeassistant/components/compit/fan.py b/homeassistant/components/compit/fan.py new file mode 100644 index 00000000000000..deedd509529e6d --- /dev/null +++ b/homeassistant/components/compit/fan.py @@ -0,0 +1,172 @@ +"""Fan platform for Compit integration.""" + +from typing import Any + +from compit_inext_api import PARAM_VALUES +from compit_inext_api.consts import CompitParameter + +from homeassistant.components.fan import ( + FanEntity, + FanEntityDescription, + FanEntityFeature, +) +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, +) + +from .const import DOMAIN, MANUFACTURER_NAME +from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator + +PARALLEL_UPDATES = 0 + +COMPIT_GEAR_TO_HA = PARAM_VALUES[CompitParameter.VENTILATION_GEAR_TARGET] +HA_STATE_TO_COMPIT = {value: key for key, value in COMPIT_GEAR_TO_HA.items()} + + +DEVICE_DEFINITIONS: dict[int, FanEntityDescription] = { + 223: FanEntityDescription( + key="Nano Color 2", + translation_key="ventilation", + ), + 12: FanEntityDescription( + key="Nano Color", + translation_key="ventilation", + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CompitConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Compit fan entities from a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + CompitFan( + coordinator, + device_id, + device_definition, + ) + for device_id, device in coordinator.connector.all_devices.items() + if (device_definition := DEVICE_DEFINITIONS.get(device.definition.code)) + ) + + +class CompitFan(CoordinatorEntity[CompitDataUpdateCoordinator], FanEntity): + """Representation of a Compit fan entity.""" + + _attr_speed_count = len(COMPIT_GEAR_TO_HA) + _attr_has_entity_name = True + _attr_name = None + _attr_supported_features = ( + FanEntityFeature.TURN_ON + | FanEntityFeature.TURN_OFF + | FanEntityFeature.SET_SPEED + ) + + def __init__( + self, + coordinator: CompitDataUpdateCoordinator, + device_id: int, + entity_description: FanEntityDescription, + ) -> None: + """Initialize the fan entity.""" + super().__init__(coordinator) + self.device_id = device_id + self.entity_description = entity_description + self._attr_unique_id = f"{device_id}_{entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(device_id))}, + name=entity_description.key, + manufacturer=MANUFACTURER_NAME, + model=entity_description.key, + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return ( + super().available + and self.coordinator.connector.get_device(self.device_id) is not None + ) + + @property + def is_on(self) -> bool | None: + """Return true if the fan is on.""" + value = self.coordinator.connector.get_current_option( + self.device_id, CompitParameter.VENTILATION_ON_OFF + ) + + return True if value == STATE_ON else False if value == STATE_OFF else None + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the fan.""" + await self.coordinator.connector.select_device_option( + self.device_id, CompitParameter.VENTILATION_ON_OFF, STATE_ON + ) + + if percentage is None: + self.async_write_ha_state() + return + + await self.async_set_percentage(percentage) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the fan.""" + await self.coordinator.connector.select_device_option( + self.device_id, CompitParameter.VENTILATION_ON_OFF, STATE_OFF + ) + self.async_write_ha_state() + + @property + def percentage(self) -> int | None: + """Return the current fan speed as a percentage.""" + if self.is_on is False: + return 0 + mode = self.coordinator.connector.get_current_option( + self.device_id, CompitParameter.VENTILATION_GEAR_TARGET + ) + if mode is None: + return None + gear = COMPIT_GEAR_TO_HA.get(mode) + return ( + None + if gear is None + else ordered_list_item_to_percentage( + list(COMPIT_GEAR_TO_HA.values()), + gear, + ) + ) + + async def async_set_percentage(self, percentage: int) -> None: + """Set the fan speed.""" + if percentage == 0: + await self.async_turn_off() + return + + gear = int( + percentage_to_ordered_list_item( + list(COMPIT_GEAR_TO_HA.values()), + percentage, + ) + ) + mode = HA_STATE_TO_COMPIT.get(gear) + if mode is None: + return + + await self.coordinator.connector.select_device_option( + self.device_id, CompitParameter.VENTILATION_GEAR_TARGET, mode + ) + self.async_write_ha_state() diff --git a/homeassistant/components/compit/icons.json b/homeassistant/components/compit/icons.json index f044f8b693dc8e..7a98b01ef7eeba 100644 --- a/homeassistant/components/compit/icons.json +++ b/homeassistant/components/compit/icons.json @@ -1,5 +1,33 @@ { "entity": { + "binary_sensor": { + "airing": { + "default": "mdi:window-open-variant" + }, + "co2_alert": { + "default": "mdi:alert" + }, + "co2_level": { + "default": "mdi:molecule-co2" + }, + "dust_alert": { + "default": "mdi:alert" + }, + "pump_status": { + "default": "mdi:pump" + }, + "temperature_alert": { + "default": "mdi:alert" + } + }, + "fan": { + "ventilation": { + "default": "mdi:fan", + "state": { + "off": "mdi:fan-off" + } + } + }, "number": { "boiler_target_temperature": { "default": "mdi:water-boiler" @@ -138,6 +166,119 @@ "winter": "mdi:snowflake" } } + }, + "sensor": { + "alarm_code": { + "default": "mdi:alert-circle", + "state": { + "no_alarm": "mdi:check-circle" + } + }, + "battery_level": { + "default": "mdi:battery" + }, + "boiler_temperature": { + "default": "mdi:thermometer" + }, + "calculated_heating_temperature": { + "default": "mdi:thermometer" + }, + "calculated_target_temperature": { + "default": "mdi:thermometer" + }, + "charging_power": { + "default": "mdi:flash" + }, + "circuit_target_temperature": { + "default": "mdi:thermometer" + }, + "co2_percent": { + "default": "mdi:molecule-co2" + }, + "collector_power": { + "default": "mdi:solar-power" + }, + "collector_temperature": { + "default": "mdi:thermometer" + }, + "dhw_measured_temperature": { + "default": "mdi:thermometer" + }, + "energy_consumption": { + "default": "mdi:lightning-bolt" + }, + "energy_smart_grid_yesterday": { + "default": "mdi:lightning-bolt" + }, + "energy_today": { + "default": "mdi:lightning-bolt" + }, + "energy_total": { + "default": "mdi:lightning-bolt" + }, + "energy_yesterday": { + "default": "mdi:lightning-bolt" + }, + "fuel_level": { + "default": "mdi:gauge" + }, + "humidity": { + "default": "mdi:water-percent" + }, + "mixer_temperature": { + "default": "mdi:thermometer" + }, + "outdoor_temperature": { + "default": "mdi:thermometer" + }, + "pk1_function": { + "default": "mdi:cog", + "state": { + "cooling": "mdi:snowflake-thermometer", + "off": "mdi:cog-off", + "summer": "mdi:weather-sunny", + "winter": "mdi:snowflake" + } + }, + "pm10_level": { + "default": "mdi:air-filter", + "state": { + "exceeded": "mdi:alert", + "no_sensor": "mdi:cancel", + "normal": "mdi:air-filter", + "warning": "mdi:alert-circle-outline" + } + }, + "pm25_level": { + "default": "mdi:air-filter", + "state": { + "exceeded": "mdi:alert", + "no_sensor": "mdi:cancel", + "normal": "mdi:air-filter", + "warning": "mdi:alert-circle-outline" + } + }, + "return_circuit_temperature": { + "default": "mdi:thermometer" + }, + "tank_temperature_t2": { + "default": "mdi:thermometer" + }, + "tank_temperature_t3": { + "default": "mdi:thermometer" + }, + "tank_temperature_t4": { + "default": "mdi:thermometer" + }, + "target_heating_temperature": { + "default": "mdi:thermometer" + }, + "ventilation_alarm": { + "default": "mdi:alert", + "state": { + "no_alarm": "mdi:check-circle" + } + } } } } diff --git a/homeassistant/components/compit/sensor.py b/homeassistant/components/compit/sensor.py new file mode 100644 index 00000000000000..3d23477f4f3ceb --- /dev/null +++ b/homeassistant/components/compit/sensor.py @@ -0,0 +1,1029 @@ +"""Sensor platform for Compit integration.""" + +from dataclasses import dataclass + +from compit_inext_api.consts import CompitParameter + +from homeassistant.components.sensor import ( + EntityCategory, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + UnitOfElectricCurrent, + UnitOfEnergy, + UnitOfPower, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER_NAME +from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator + +PARALLEL_UPDATES = 0 +NO_SENSOR = "no_sensor" + +DESCRIPTIONS: dict[CompitParameter, SensorEntityDescription] = { + CompitParameter.ACTUAL_BUFFER_TEMP: SensorEntityDescription( + key=CompitParameter.ACTUAL_BUFFER_TEMP.value, + translation_key="actual_buffer_temp", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + CompitParameter.ACTUAL_DHW_TEMP: SensorEntityDescription( + key=CompitParameter.ACTUAL_DHW_TEMP.value, + translation_key="actual_dhw_temp", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + CompitParameter.ACTUAL_HC1_TEMPERATURE: SensorEntityDescription( + key=CompitParameter.ACTUAL_HC1_TEMPERATURE.value, + translation_key="actual_hc_temperature_zone", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + translation_placeholders={"zone": "1"}, + ), + CompitParameter.ACTUAL_HC2_TEMPERATURE: SensorEntityDescription( + key=CompitParameter.ACTUAL_HC2_TEMPERATURE.value, + translation_key="actual_hc_temperature_zone", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + translation_placeholders={"zone": "2"}, + ), + CompitParameter.ACTUAL_HC3_TEMPERATURE: SensorEntityDescription( + key=CompitParameter.ACTUAL_HC3_TEMPERATURE.value, + translation_key="actual_hc_temperature_zone", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + translation_placeholders={"zone": "3"}, + ), + CompitParameter.ACTUAL_HC4_TEMPERATURE: SensorEntityDescription( + key=CompitParameter.ACTUAL_HC4_TEMPERATURE.value, + translation_key="actual_hc_temperature_zone", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + translation_placeholders={"zone": "4"}, + ), + CompitParameter.ACTUAL_UPPER_SOURCE_TEMP: SensorEntityDescription( + key=CompitParameter.ACTUAL_UPPER_SOURCE_TEMP.value, + translation_key="actual_upper_source_temp", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + CompitParameter.ALARM_CODE: SensorEntityDescription( + key=CompitParameter.ALARM_CODE.value, + translation_key="alarm_code", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=[ + "no_alarm", + "damaged_outdoor_temp", + "damaged_return_temp", + "no_battery", + "discharged_battery", + "low_battery_level", + "battery_fault", + "no_pump", + "pump_fault", + "internal_af", + "no_power", + ], + ), + CompitParameter.BATTERY_LEVEL: SensorEntityDescription( + key=CompitParameter.BATTERY_LEVEL.value, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + ), + CompitParameter.BOILER_TEMPERATURE: SensorEntityDescription( + key=CompitParameter.BOILER_TEMPERATURE.value, + translation_key="boiler_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + CompitParameter.BUFFER_RETURN_TEMPERATURE: SensorEntityDescription( + key=CompitParameter.BUFFER_RETURN_TEMPERATURE.value, + translation_key="buffer_return_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + CompitParameter.BUFFER_SET_TEMPERATURE: SensorEntityDescription( + key=CompitParameter.BUFFER_SET_TEMPERATURE.value, + translation_key="buffer_set_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + CompitParameter.CALCULATED_BUFFER_TEMP: SensorEntityDescription( + key=CompitParameter.CALCULATED_BUFFER_TEMP.value, + translation_key="calculated_buffer_temp", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + CompitParameter.CALCULATED_DHW_TEMP: SensorEntityDescription( + key=CompitParameter.CALCULATED_DHW_TEMP.value, + translation_key="calculated_dhw_temp", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + CompitParameter.CALCULATED_HEATING_TEMPERATURE: SensorEntityDescription( + key=CompitParameter.CALCULATED_HEATING_TEMPERATURE.value, + translation_key="calculated_heating_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + CompitParameter.CALCULATED_TARGET_TEMPERATURE: SensorEntityDescription( + key=CompitParameter.CALCULATED_TARGET_TEMPERATURE.value, + translation_key="calculated_target_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + CompitParameter.CALCULATED_UPPER_SOURCE_TEMP: SensorEntityDescription( + key=CompitParameter.CALCULATED_UPPER_SOURCE_TEMP.value, + translation_key="calculated_upper_source_temp", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + CompitParameter.CHARGING_POWER: SensorEntityDescription( + key=CompitParameter.CHARGING_POWER.value, + translation_key="charging_power", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + ), + CompitParameter.CIRCUIT_TARGET_TEMPERATURE: SensorEntityDescription( + key=CompitParameter.CIRCUIT_TARGET_TEMPERATURE.value, + translation_key="circuit_target_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + CompitParameter.CO2_LEVEL: SensorEntityDescription( + key=CompitParameter.CO2_LEVEL.value, + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + ), + CompitParameter.CO2_PERCENT: SensorEntityDescription( + key=CompitParameter.CO2_PERCENT.value, + translation_key="co2_percent", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + ), + CompitParameter.COLLECTOR_POWER: SensorEntityDescription( + key=CompitParameter.COLLECTOR_POWER.value, + translation_key="collector_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + ), + CompitParameter.COLLECTOR_TEMPERATURE: SensorEntityDescription( + key=CompitParameter.COLLECTOR_TEMPERATURE.value, + translation_key="collector_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + CompitParameter.DHW_MEASURED_TEMPERATURE: SensorEntityDescription( + key=CompitParameter.DHW_MEASURED_TEMPERATURE.value, + translation_key="dhw_measured_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + CompitParameter.DHW_TEMPERATURE: SensorEntityDescription( + key=CompitParameter.DHW_TEMPERATURE.value, + translation_key="dhw_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + CompitParameter.ENERGY_CONSUMPTION: SensorEntityDescription( + key=CompitParameter.ENERGY_CONSUMPTION.value, + translation_key="energy_consumption", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.MEGA_WATT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + CompitParameter.ENERGY_SGREADY_YESTERDAY: SensorEntityDescription( + key=CompitParameter.ENERGY_SGREADY_YESTERDAY.value, + translation_key="energy_smart_grid_yesterday", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + ), + CompitParameter.ENERGY_TODAY: SensorEntityDescription( + key=CompitParameter.ENERGY_TODAY.value, + translation_key="energy_today", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + ), + CompitParameter.ENERGY_TOTAL: SensorEntityDescription( + key=CompitParameter.ENERGY_TOTAL.value, + translation_key="energy_total", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + ), + CompitParameter.ENERGY_YESTERDAY: SensorEntityDescription( + key=CompitParameter.ENERGY_YESTERDAY.value, + translation_key="energy_yesterday", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + ), + CompitParameter.FUEL_LEVEL: SensorEntityDescription( + key=CompitParameter.FUEL_LEVEL.value, + translation_key="fuel_level", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + ), + CompitParameter.HEATING1_TARGET_TEMPERATURE: SensorEntityDescription( + key=CompitParameter.HEATING1_TARGET_TEMPERATURE.value, + translation_key="heating_target_temperature_zone", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + translation_placeholders={"zone": "1"}, + ), + CompitParameter.HEATING2_TARGET_TEMPERATURE: SensorEntityDescription( + key=CompitParameter.HEATING2_TARGET_TEMPERATURE.value, + translation_key="heating_target_temperature_zone", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + translation_placeholders={"zone": "2"}, + ), + CompitParameter.HEATING3_TARGET_TEMPERATURE: SensorEntityDescription( + key=CompitParameter.HEATING3_TARGET_TEMPERATURE.value, + translation_key="heating_target_temperature_zone", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + translation_placeholders={"zone": "3"}, + ), + CompitParameter.HEATING4_TARGET_TEMPERATURE: SensorEntityDescription( + key=CompitParameter.HEATING4_TARGET_TEMPERATURE.value, + translation_key="heating_target_temperature_zone", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + translation_placeholders={"zone": "4"}, + ), + CompitParameter.HUMIDITY: SensorEntityDescription( + key=CompitParameter.HUMIDITY.value, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + ), + CompitParameter.LOWER_SOURCE_TEMPERATURE: SensorEntityDescription( + key=CompitParameter.LOWER_SOURCE_TEMPERATURE.value, + translation_key="lower_source_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + CompitParameter.MIXER_TEMPERATURE: SensorEntityDescription( + key=CompitParameter.MIXER_TEMPERATURE.value, + translation_key="mixer_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + CompitParameter.MIXER1_TEMPERATURE: SensorEntityDescription( + key=CompitParameter.MIXER1_TEMPERATURE.value, + translation_key="mixer_temperature_zone", + translation_placeholders={"zone": "1"}, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + CompitParameter.MIXER2_TEMPERATURE: SensorEntityDescription( + key=CompitParameter.MIXER2_TEMPERATURE.value, + translation_key="mixer_temperature_zone", + translation_placeholders={"zone": "2"}, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + CompitParameter.OUTDOOR_TEMPERATURE: SensorEntityDescription( + key=CompitParameter.OUTDOOR_TEMPERATURE.value, + translation_key="outdoor_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + CompitParameter.PK1_FUNCTION: SensorEntityDescription( + key=CompitParameter.PK1_FUNCTION.value, + translation_key="pk1_function", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=[ + "off", + "on", + "nano_nr_1", + "nano_nr_2", + "nano_nr_3", + "nano_nr_4", + "nano_nr_5", + "winter", + "summer", + "cooling", + "holiday", + ], + ), + CompitParameter.PM1_LEVEL_MEASURED: SensorEntityDescription( + key=CompitParameter.PM1_LEVEL_MEASURED.value, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.PM1, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + CompitParameter.PM4_LEVEL_MEASURED: SensorEntityDescription( + key=CompitParameter.PM4_LEVEL_MEASURED.value, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.PM4, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + CompitParameter.PM10_LEVEL: SensorEntityDescription( + key=CompitParameter.PM10_LEVEL.value, + translation_key="pm10_level", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + options=[NO_SENSOR, "normal", "warning", "exceeded"], + ), + CompitParameter.PM10_MEASURED: SensorEntityDescription( + key=CompitParameter.PM10_MEASURED.value, + device_class=SensorDeviceClass.PM10, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + CompitParameter.PM25_LEVEL: SensorEntityDescription( + key=CompitParameter.PM25_LEVEL.value, + translation_key="pm25_level", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + options=[NO_SENSOR, "normal", "warning", "exceeded"], + ), + CompitParameter.PM25_MEASURED: SensorEntityDescription( + key=CompitParameter.PM25_MEASURED.value, + device_class=SensorDeviceClass.PM25, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + CompitParameter.PROTECTION_TEMPERATURE: SensorEntityDescription( + key=CompitParameter.PROTECTION_TEMPERATURE.value, + translation_key="protection_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + CompitParameter.RETURN_CIRCUIT_TEMPERATURE: SensorEntityDescription( + key=CompitParameter.RETURN_CIRCUIT_TEMPERATURE.value, + translation_key="return_circuit_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + CompitParameter.TANK_BOTTOM_T2_TEMPERATURE: SensorEntityDescription( + key=CompitParameter.TANK_BOTTOM_T2_TEMPERATURE.value, + translation_key="tank_temperature_t2", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + CompitParameter.TANK_T4_TEMPERATURE: SensorEntityDescription( + key=CompitParameter.TANK_T4_TEMPERATURE.value, + translation_key="tank_temperature_t4", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + translation_placeholders={"sensor": "T4"}, + ), + CompitParameter.TANK_TOP_T3_TEMPERATURE: SensorEntityDescription( + key=CompitParameter.TANK_TOP_T3_TEMPERATURE.value, + translation_key="tank_temperature_t3", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + CompitParameter.TARGET_HEATING_TEMPERATURE: SensorEntityDescription( + key=CompitParameter.TARGET_HEATING_TEMPERATURE.value, + translation_key="target_heating_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + CompitParameter.UPPER_SOURCE_TEMPERATURE: SensorEntityDescription( + key=CompitParameter.UPPER_SOURCE_TEMPERATURE.value, + translation_key="upper_source_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + CompitParameter.VENTILATION_ALARM: SensorEntityDescription( + key=CompitParameter.VENTILATION_ALARM.value, + translation_key="ventilation_alarm", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=[ + "no_alarm", + "damaged_supply_sensor", + "damaged_exhaust_sensor", + "damaged_supply_and_exhaust_sensors", + "bot_alarm", + "damaged_preheater_sensor", + "ahu_alarm", + ], + ), + CompitParameter.VENTILATION_GEAR: SensorEntityDescription( + key=CompitParameter.VENTILATION_GEAR.value, + translation_key="ventilation_gear", + entity_category=EntityCategory.DIAGNOSTIC, + ), +} + + +@dataclass(frozen=True, kw_only=True) +class CompitDeviceDescription: + """Class to describe a Compit device.""" + + name: str + """Name of the device.""" + + parameters: dict[CompitParameter, SensorEntityDescription] + """Parameters of the device.""" + + +DEVICE_DEFINITIONS: dict[int, CompitDeviceDescription] = { + 3: CompitDeviceDescription( + name="R 810", + parameters={ + CompitParameter.CALCULATED_HEATING_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.CALCULATED_HEATING_TEMPERATURE + ], + CompitParameter.OUTDOOR_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.OUTDOOR_TEMPERATURE + ], + CompitParameter.RETURN_CIRCUIT_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.RETURN_CIRCUIT_TEMPERATURE + ], + CompitParameter.TARGET_HEATING_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.TARGET_HEATING_TEMPERATURE + ], + }, + ), + 5: CompitDeviceDescription( + name="R350 T3", + parameters={ + CompitParameter.CALCULATED_TARGET_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.CALCULATED_TARGET_TEMPERATURE + ], + CompitParameter.CIRCUIT_TARGET_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.CIRCUIT_TARGET_TEMPERATURE + ], + CompitParameter.MIXER_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.MIXER_TEMPERATURE + ], + CompitParameter.OUTDOOR_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.OUTDOOR_TEMPERATURE + ], + }, + ), + 12: CompitDeviceDescription( + name="Nano Color", + parameters={ + CompitParameter.OUTDOOR_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.OUTDOOR_TEMPERATURE + ], + CompitParameter.PM10_LEVEL: DESCRIPTIONS[CompitParameter.PM10_LEVEL], + CompitParameter.PM25_LEVEL: DESCRIPTIONS[CompitParameter.PM25_LEVEL], + CompitParameter.VENTILATION_ALARM: DESCRIPTIONS[ + CompitParameter.VENTILATION_ALARM + ], + }, + ), + 14: CompitDeviceDescription( + name="BWC310", + parameters={ + CompitParameter.CALCULATED_HEATING_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.CALCULATED_HEATING_TEMPERATURE + ], + CompitParameter.TARGET_HEATING_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.TARGET_HEATING_TEMPERATURE + ], + }, + ), + 27: CompitDeviceDescription( + name="CO2 SHC", + parameters={ + CompitParameter.HUMIDITY: DESCRIPTIONS[CompitParameter.HUMIDITY], + CompitParameter.OUTDOOR_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.OUTDOOR_TEMPERATURE + ], + }, + ), + 34: CompitDeviceDescription( + name="r470", + parameters={ + CompitParameter.OUTDOOR_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.OUTDOOR_TEMPERATURE + ], + }, + ), + 36: CompitDeviceDescription( + name="BioMax742", + parameters={ + CompitParameter.BOILER_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.BOILER_TEMPERATURE + ], + CompitParameter.FUEL_LEVEL: DESCRIPTIONS[CompitParameter.FUEL_LEVEL], + CompitParameter.OUTDOOR_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.OUTDOOR_TEMPERATURE + ], + }, + ), + 44: CompitDeviceDescription( + name="SolarComp 951", + parameters={ + CompitParameter.COLLECTOR_POWER: DESCRIPTIONS[ + CompitParameter.COLLECTOR_POWER + ], + CompitParameter.COLLECTOR_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.COLLECTOR_TEMPERATURE + ], + CompitParameter.TANK_BOTTOM_T2_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.TANK_BOTTOM_T2_TEMPERATURE + ], + CompitParameter.TANK_T4_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.TANK_T4_TEMPERATURE + ], + CompitParameter.TANK_TOP_T3_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.TANK_TOP_T3_TEMPERATURE + ], + }, + ), + 45: CompitDeviceDescription( + name="SolarComp971", + parameters={ + CompitParameter.COLLECTOR_POWER: DESCRIPTIONS[ + CompitParameter.COLLECTOR_POWER + ], + CompitParameter.COLLECTOR_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.COLLECTOR_TEMPERATURE + ], + CompitParameter.ENERGY_TODAY: DESCRIPTIONS[CompitParameter.ENERGY_TODAY], + CompitParameter.TANK_BOTTOM_T2_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.TANK_BOTTOM_T2_TEMPERATURE + ], + CompitParameter.TANK_TOP_T3_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.TANK_TOP_T3_TEMPERATURE + ], + }, + ), + 53: CompitDeviceDescription( + name="R350.CWU", + parameters={ + CompitParameter.CALCULATED_TARGET_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.CALCULATED_TARGET_TEMPERATURE + ], + CompitParameter.DHW_MEASURED_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.DHW_MEASURED_TEMPERATURE + ], + CompitParameter.ENERGY_SGREADY_YESTERDAY: DESCRIPTIONS[ + CompitParameter.ENERGY_SGREADY_YESTERDAY + ], + CompitParameter.ENERGY_TOTAL: DESCRIPTIONS[CompitParameter.ENERGY_TOTAL], + CompitParameter.ENERGY_YESTERDAY: DESCRIPTIONS[ + CompitParameter.ENERGY_YESTERDAY + ], + CompitParameter.OUTDOOR_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.OUTDOOR_TEMPERATURE + ], + }, + ), + 58: CompitDeviceDescription( + name="SolarComp 971SD1", + parameters={ + CompitParameter.ENERGY_CONSUMPTION: DESCRIPTIONS[ + CompitParameter.ENERGY_CONSUMPTION + ], + }, + ), + 75: CompitDeviceDescription( + name="BioMax772", + parameters={ + CompitParameter.BOILER_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.BOILER_TEMPERATURE + ], + CompitParameter.FUEL_LEVEL: DESCRIPTIONS[CompitParameter.FUEL_LEVEL], + CompitParameter.OUTDOOR_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.OUTDOOR_TEMPERATURE + ], + }, + ), + 78: CompitDeviceDescription( + name="SPM - Nano Color 2", + parameters={ + CompitParameter.CO2_LEVEL: DESCRIPTIONS[CompitParameter.CO2_LEVEL], + CompitParameter.CO2_PERCENT: DESCRIPTIONS[CompitParameter.CO2_PERCENT], + CompitParameter.HUMIDITY: DESCRIPTIONS[CompitParameter.HUMIDITY], + CompitParameter.OUTDOOR_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.OUTDOOR_TEMPERATURE + ], + CompitParameter.PM1_LEVEL_MEASURED: DESCRIPTIONS[ + CompitParameter.PM1_LEVEL_MEASURED + ], + CompitParameter.PM4_LEVEL_MEASURED: DESCRIPTIONS[ + CompitParameter.PM4_LEVEL_MEASURED + ], + CompitParameter.PM10_MEASURED: DESCRIPTIONS[CompitParameter.PM10_MEASURED], + CompitParameter.PM25_MEASURED: DESCRIPTIONS[CompitParameter.PM25_MEASURED], + }, + ), + 91: CompitDeviceDescription( + name="R770RS / R771RS ", + parameters={ + CompitParameter.BOILER_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.BOILER_TEMPERATURE + ], + CompitParameter.FUEL_LEVEL: DESCRIPTIONS[CompitParameter.FUEL_LEVEL], + CompitParameter.MIXER1_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.MIXER1_TEMPERATURE + ], + CompitParameter.MIXER2_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.MIXER2_TEMPERATURE + ], + CompitParameter.OUTDOOR_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.OUTDOOR_TEMPERATURE + ], + }, + ), + 92: CompitDeviceDescription( + name="r490", + parameters={ + CompitParameter.LOWER_SOURCE_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.LOWER_SOURCE_TEMPERATURE + ], + CompitParameter.UPPER_SOURCE_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.UPPER_SOURCE_TEMPERATURE + ], + }, + ), + 99: CompitDeviceDescription( + name="SolarComp971C", + parameters={ + CompitParameter.COLLECTOR_POWER: DESCRIPTIONS[ + CompitParameter.COLLECTOR_POWER + ], + CompitParameter.COLLECTOR_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.COLLECTOR_TEMPERATURE + ], + CompitParameter.ENERGY_TODAY: DESCRIPTIONS[CompitParameter.ENERGY_TODAY], + CompitParameter.TANK_BOTTOM_T2_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.TANK_BOTTOM_T2_TEMPERATURE + ], + CompitParameter.TANK_TOP_T3_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.TANK_TOP_T3_TEMPERATURE + ], + }, + ), + 201: CompitDeviceDescription( + name="BioMax775", + parameters={ + CompitParameter.BOILER_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.BOILER_TEMPERATURE + ], + CompitParameter.FUEL_LEVEL: DESCRIPTIONS[CompitParameter.FUEL_LEVEL], + CompitParameter.OUTDOOR_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.OUTDOOR_TEMPERATURE + ], + }, + ), + 210: CompitDeviceDescription( + name="EL750", + parameters={ + CompitParameter.BOILER_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.BOILER_TEMPERATURE + ], + CompitParameter.BUFFER_RETURN_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.BUFFER_RETURN_TEMPERATURE + ], + CompitParameter.DHW_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.DHW_TEMPERATURE + ], + }, + ), + 212: CompitDeviceDescription( + name="BioMax742", + parameters={ + CompitParameter.BOILER_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.BOILER_TEMPERATURE + ], + CompitParameter.FUEL_LEVEL: DESCRIPTIONS[CompitParameter.FUEL_LEVEL], + CompitParameter.OUTDOOR_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.OUTDOOR_TEMPERATURE + ], + }, + ), + 215: CompitDeviceDescription( + name="R480", + parameters={ + CompitParameter.ACTUAL_BUFFER_TEMP: DESCRIPTIONS[ + CompitParameter.ACTUAL_BUFFER_TEMP + ], + CompitParameter.ACTUAL_DHW_TEMP: DESCRIPTIONS[ + CompitParameter.ACTUAL_DHW_TEMP + ], + CompitParameter.DHW_MEASURED_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.DHW_MEASURED_TEMPERATURE + ], + }, + ), + 221: CompitDeviceDescription( + name="R350.M", + parameters={ + CompitParameter.MIXER_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.MIXER_TEMPERATURE + ], + CompitParameter.PROTECTION_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.PROTECTION_TEMPERATURE + ], + CompitParameter.OUTDOOR_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.OUTDOOR_TEMPERATURE + ], + }, + ), + 222: CompitDeviceDescription( + name="R377B", + parameters={ + CompitParameter.BUFFER_SET_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.BUFFER_SET_TEMPERATURE + ], + }, + ), + 223: CompitDeviceDescription( + name="Nano Color 2", + parameters={ + CompitParameter.OUTDOOR_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.OUTDOOR_TEMPERATURE + ], + CompitParameter.PM10_LEVEL: DESCRIPTIONS[CompitParameter.PM10_LEVEL], + CompitParameter.PM25_LEVEL: DESCRIPTIONS[CompitParameter.PM25_LEVEL], + CompitParameter.VENTILATION_ALARM: DESCRIPTIONS[ + CompitParameter.VENTILATION_ALARM + ], + CompitParameter.VENTILATION_GEAR: DESCRIPTIONS[ + CompitParameter.VENTILATION_GEAR + ], + }, + ), + 224: CompitDeviceDescription( + name="R 900", + parameters={ + CompitParameter.ACTUAL_BUFFER_TEMP: DESCRIPTIONS[ + CompitParameter.ACTUAL_BUFFER_TEMP + ], + CompitParameter.ACTUAL_HC1_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.ACTUAL_HC1_TEMPERATURE + ], + CompitParameter.ACTUAL_HC2_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.ACTUAL_HC2_TEMPERATURE + ], + CompitParameter.ACTUAL_HC3_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.ACTUAL_HC3_TEMPERATURE + ], + CompitParameter.ACTUAL_HC4_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.ACTUAL_HC4_TEMPERATURE + ], + CompitParameter.ACTUAL_DHW_TEMP: DESCRIPTIONS[ + CompitParameter.ACTUAL_DHW_TEMP + ], + CompitParameter.ACTUAL_UPPER_SOURCE_TEMP: DESCRIPTIONS[ + CompitParameter.ACTUAL_UPPER_SOURCE_TEMP + ], + CompitParameter.CALCULATED_BUFFER_TEMP: DESCRIPTIONS[ + CompitParameter.CALCULATED_BUFFER_TEMP + ], + CompitParameter.CALCULATED_DHW_TEMP: DESCRIPTIONS[ + CompitParameter.CALCULATED_DHW_TEMP + ], + CompitParameter.CALCULATED_UPPER_SOURCE_TEMP: DESCRIPTIONS[ + CompitParameter.CALCULATED_UPPER_SOURCE_TEMP + ], + CompitParameter.HEATING1_TARGET_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.HEATING1_TARGET_TEMPERATURE + ], + CompitParameter.HEATING2_TARGET_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.HEATING2_TARGET_TEMPERATURE + ], + CompitParameter.HEATING3_TARGET_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.HEATING3_TARGET_TEMPERATURE + ], + CompitParameter.HEATING4_TARGET_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.HEATING4_TARGET_TEMPERATURE + ], + CompitParameter.OUTDOOR_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.OUTDOOR_TEMPERATURE + ], + }, + ), + 225: CompitDeviceDescription( + name="SPM - Nano Color", + parameters={ + CompitParameter.HUMIDITY: DESCRIPTIONS[CompitParameter.HUMIDITY], + CompitParameter.OUTDOOR_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.OUTDOOR_TEMPERATURE + ], + CompitParameter.PM10_MEASURED: DESCRIPTIONS[CompitParameter.PM10_MEASURED], + CompitParameter.PM25_MEASURED: DESCRIPTIONS[CompitParameter.PM25_MEASURED], + }, + ), + 226: CompitDeviceDescription( + name="AF-1", + parameters={ + CompitParameter.ALARM_CODE: DESCRIPTIONS[CompitParameter.ALARM_CODE], + CompitParameter.BATTERY_LEVEL: DESCRIPTIONS[CompitParameter.BATTERY_LEVEL], + CompitParameter.CHARGING_POWER: DESCRIPTIONS[ + CompitParameter.CHARGING_POWER + ], + CompitParameter.OUTDOOR_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.OUTDOOR_TEMPERATURE + ], + CompitParameter.RETURN_CIRCUIT_TEMPERATURE: DESCRIPTIONS[ + CompitParameter.RETURN_CIRCUIT_TEMPERATURE + ], + }, + ), + 227: CompitDeviceDescription( + name="Combo", + parameters={ + CompitParameter.PK1_FUNCTION: DESCRIPTIONS[CompitParameter.PK1_FUNCTION], + }, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CompitConfigEntry, + async_add_devices: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Compit sensor entities from a config entry.""" + + coordinator = entry.runtime_data + sensor_entities = [] + for device_id, device in coordinator.connector.all_devices.items(): + device_definition = DEVICE_DEFINITIONS.get(device.definition.code) + + if not device_definition: + continue + + for code, entity_description in device_definition.parameters.items(): + if ( + entity_description.options + and NO_SENSOR in entity_description.options + and ( + coordinator.connector.get_current_value(device_id, code) + == NO_SENSOR + ) + ): + continue + + sensor_entities.append( + CompitSensor( + coordinator, + device_id, + device_definition.name, + code, + entity_description, + ) + ) + + async_add_devices(sensor_entities) + + +class CompitSensor(CoordinatorEntity[CompitDataUpdateCoordinator], SensorEntity): + """Representation of a Compit sensor entity.""" + + def __init__( + self, + coordinator: CompitDataUpdateCoordinator, + device_id: int, + device_name: str, + parameter_code: CompitParameter, + entity_description: SensorEntityDescription, + ) -> None: + """Initialize the sensor entity.""" + super().__init__(coordinator) + self.device_id = device_id + self.entity_description = entity_description + self._attr_has_entity_name = True + self._attr_unique_id = f"{device_id}_{entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(device_id))}, + name=device_name, + manufacturer=MANUFACTURER_NAME, + model=device_name, + ) + self.parameter_code = parameter_code + + @property + def available(self) -> bool: + """Return if entity is available.""" + return ( + super().available + and self.coordinator.connector.get_device(self.device_id) is not None + ) + + @property + def native_value(self) -> float | str | None: + """Return the state of the sensor.""" + value = self.coordinator.connector.get_current_value( + self.device_id, self.parameter_code + ) + + if ( + isinstance(value, str) + and self.entity_description.options + and value in self.entity_description.options + ): + return value + + if isinstance(value, (int, float)): + return value + + return None diff --git a/homeassistant/components/compit/strings.json b/homeassistant/components/compit/strings.json index 6bc4df5814eb92..56624669e0d8fe 100644 --- a/homeassistant/components/compit/strings.json +++ b/homeassistant/components/compit/strings.json @@ -33,6 +33,31 @@ } }, "entity": { + "binary_sensor": { + "airing": { + "name": "Airing" + }, + "co2_alert": { + "name": "CO2 alert" + }, + "co2_level": { + "name": "CO2 level" + }, + "dust_alert": { + "name": "Dust alert" + }, + "pump_status": { + "name": "Pump status" + }, + "temperature_alert": { + "name": "Temperature alert" + } + }, + "fan": { + "ventilation": { + "name": "[%key:component::fan::title%]" + } + }, "number": { "boiler_target_temperature": { "name": "Boiler target temperature" @@ -183,6 +208,219 @@ "winter": "Winter" } } + }, + "sensor": { + "actual_buffer_temp": { + "name": "Actual buffer temperature" + }, + "actual_dhw_temp": { + "name": "Actual DHW temperature" + }, + "actual_hc_temperature_zone": { + "name": "Actual heating circuit {zone} temperature" + }, + "actual_upper_source_temp": { + "name": "Actual upper source temperature" + }, + "alarm_code": { + "name": "Alarm code", + "state": { + "battery_fault": "Battery fault", + "damaged_outdoor_temp": "Damaged outdoor temperature sensor", + "damaged_return_temp": "Damaged return temperature sensor", + "discharged_battery": "Discharged battery", + "internal_af": "Internal fault", + "low_battery_level": "Low battery level", + "no_alarm": "No alarm", + "no_battery": "No battery", + "no_power": "No power", + "no_pump": "No pump", + "pump_fault": "Pump fault" + } + }, + "battery_level": { + "name": "Battery level" + }, + "boiler_temperature": { + "name": "Boiler temperature" + }, + "buffer_return_temperature": { + "name": "Buffer return temperature" + }, + "buffer_set_temperature": { + "name": "Buffer set temperature" + }, + "calculated_buffer_temp": { + "name": "Calculated buffer temperature" + }, + "calculated_dhw_temp": { + "name": "Calculated DHW temperature" + }, + "calculated_heating_temperature": { + "name": "Calculated heating temperature" + }, + "calculated_target_temperature": { + "name": "Calculated target temperature" + }, + "calculated_upper_source_temp": { + "name": "Calculated upper source temperature" + }, + "charging_power": { + "name": "Charging power" + }, + "circuit_target_temperature": { + "name": "Circuit target temperature" + }, + "co2_percent": { + "name": "CO2 percent" + }, + "collector_power": { + "name": "Collector power" + }, + "collector_temperature": { + "name": "Collector temperature" + }, + "dhw_measured_temperature": { + "name": "DHW measured temperature" + }, + "dhw_temperature": { + "name": "DHW temperature" + }, + "energy_consumption": { + "name": "Energy consumption" + }, + "energy_smart_grid_yesterday": { + "name": "Energy smart grid yesterday" + }, + "energy_today": { + "name": "Energy today" + }, + "energy_total": { + "name": "Energy total" + }, + "energy_yesterday": { + "name": "Energy yesterday" + }, + "fuel_level": { + "name": "Fuel level" + }, + "heating_target_temperature_zone": { + "name": "Heating circuit {zone} target temperature" + }, + "lower_source_temperature": { + "name": "Lower source temperature" + }, + "mixer_temperature": { + "name": "Mixer temperature" + }, + "mixer_temperature_zone": { + "name": "Mixer {zone} temperature" + }, + "outdoor_temperature": { + "name": "Outdoor temperature" + }, + "pk1_function": { + "name": "PK1 function", + "state": { + "cooling": "Cooling", + "holiday": "Holiday", + "nano_nr_1": "Nano 1", + "nano_nr_2": "Nano 2", + "nano_nr_3": "Nano 3", + "nano_nr_4": "Nano 4", + "nano_nr_5": "Nano 5", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "summer": "Summer", + "winter": "Winter" + } + }, + "pm10_level": { + "name": "PM10 level", + "state": { + "exceeded": "Exceeded", + "no_sensor": "No sensor", + "normal": "Normal", + "warning": "Warning" + } + }, + "pm1_level": { + "name": "PM1 level" + }, + "pm25_level": { + "name": "PM2.5 level", + "state": { + "exceeded": "Exceeded", + "no_sensor": "No sensor", + "normal": "Normal", + "warning": "Warning" + } + }, + "pm4_level": { + "name": "PM4 level" + }, + "preset_mode": { + "name": "Preset mode" + }, + "protection_temperature": { + "name": "Protection temperature" + }, + "pump_status": { + "name": "Pump status", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + }, + "return_circuit_temperature": { + "name": "Return circuit temperature" + }, + "set_target_temperature": { + "name": "Set target temperature" + }, + "tank_temperature_t2": { + "name": "Tank T2 bottom temperature" + }, + "tank_temperature_t3": { + "name": "Tank T3 top temperature" + }, + "tank_temperature_t4": { + "name": "Tank T4 temperature" + }, + "target_heating_temperature": { + "name": "Target heating temperature" + }, + "target_temperature": { + "name": "Target temperature" + }, + "temperature_alert": { + "name": "Temperature alert", + "state": { + "alert": "Alert", + "no_alert": "No alert" + } + }, + "upper_source_temperature": { + "name": "Upper source temperature" + }, + "ventilation_alarm": { + "name": "Ventilation alarm", + "state": { + "ahu_alarm": "AHU alarm", + "bot_alarm": "BOT alarm", + "damaged_exhaust_sensor": "Damaged exhaust sensor", + "damaged_preheater_sensor": "Damaged preheater sensor", + "damaged_supply_and_exhaust_sensors": "Damaged supply and exhaust sensors", + "damaged_supply_sensor": "Damaged supply sensor", + "no_alarm": "No alarm" + } + }, + "ventilation_gear": { + "name": "Ventilation gear" + }, + "weather_curve": { + "name": "Weather curve" + } } } } diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index 3a593906bcd81d..ce9f315ff78037 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -153,8 +153,8 @@ def websocket_get_entities( { vol.Required("type"): "config/entity_registry/update", vol.Required("entity_id"): cv.entity_id, + vol.Optional("aliases"): [vol.Any(str, None)], # If passed in, we update value. Passing None will remove old value. - vol.Optional("aliases"): list, vol.Optional("area_id"): vol.Any(str, None), # Categories is a mapping of key/value (scope/category_id) pairs. # If passed in, we update/adjust only the provided scope(s). @@ -225,10 +225,15 @@ def websocket_update_entity( changes[key] = msg[key] if "aliases" in msg: - # Create a set for the aliases without: - # - Empty strings + # Sanitize aliases by removing: # - Trailing and leading whitespace characters in the individual aliases - changes["aliases"] = {s_strip for s in msg["aliases"] if (s_strip := s.strip())} + # - Empty strings + changes["aliases"] = aliases = [] + for alias in msg["aliases"]: + if alias is None: + aliases.append(er.COMPUTED_NAME) + elif alias := alias.strip(): + aliases.append(alias) if "labels" in msg: # Convert labels to a set diff --git a/homeassistant/components/control4/climate.py b/homeassistant/components/control4/climate.py index d28fceb8bbe8cd..ba0005cbf3ade2 100644 --- a/homeassistant/components/control4/climate.py +++ b/homeassistant/components/control4/climate.py @@ -34,20 +34,33 @@ # Control4 variable names CONTROL4_HVAC_STATE = "HVAC_STATE" CONTROL4_HVAC_MODE = "HVAC_MODE" -CONTROL4_CURRENT_TEMPERATURE = "TEMPERATURE_F" CONTROL4_HUMIDITY = "HUMIDITY" -CONTROL4_COOL_SETPOINT = "COOL_SETPOINT_F" -CONTROL4_HEAT_SETPOINT = "HEAT_SETPOINT_F" +CONTROL4_SCALE = "SCALE" # "FAHRENHEIT" or "CELSIUS" + +# Temperature variables - Fahrenheit +CONTROL4_CURRENT_TEMPERATURE_F = "TEMPERATURE_F" +CONTROL4_COOL_SETPOINT_F = "COOL_SETPOINT_F" +CONTROL4_HEAT_SETPOINT_F = "HEAT_SETPOINT_F" + +# Temperature variables - Celsius +CONTROL4_CURRENT_TEMPERATURE_C = "TEMPERATURE_C" +CONTROL4_COOL_SETPOINT_C = "COOL_SETPOINT_C" +CONTROL4_HEAT_SETPOINT_C = "HEAT_SETPOINT_C" + CONTROL4_FAN_MODE = "FAN_MODE" CONTROL4_FAN_MODES_LIST = "FAN_MODES_LIST" VARIABLES_OF_INTEREST = { CONTROL4_HVAC_STATE, CONTROL4_HVAC_MODE, - CONTROL4_CURRENT_TEMPERATURE, CONTROL4_HUMIDITY, - CONTROL4_COOL_SETPOINT, - CONTROL4_HEAT_SETPOINT, + CONTROL4_CURRENT_TEMPERATURE_F, + CONTROL4_CURRENT_TEMPERATURE_C, + CONTROL4_COOL_SETPOINT_F, + CONTROL4_HEAT_SETPOINT_F, + CONTROL4_COOL_SETPOINT_C, + CONTROL4_HEAT_SETPOINT_C, + CONTROL4_SCALE, CONTROL4_FAN_MODE, CONTROL4_FAN_MODES_LIST, } @@ -62,11 +75,12 @@ HA_TO_C4_HVAC_MODE = {v: k for k, v in C4_TO_HA_HVAC_MODE.items()} -# Map the five known Control4 HVAC states to Home Assistant HVAC actions +# Map Control4 HVAC states to Home Assistant HVAC actions C4_TO_HA_HVAC_ACTION = { "off": HVACAction.OFF, "heat": HVACAction.HEATING, "cool": HVACAction.COOLING, + "idle": HVACAction.IDLE, "dry": HVACAction.DRYING, "fan": HVACAction.FAN, } @@ -156,7 +170,6 @@ class Control4Climate(Control4Entity, ClimateEntity): """Control4 climate entity.""" _attr_has_entity_name = True - _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT _attr_translation_key = "thermostat" _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL] @@ -213,13 +226,45 @@ def supported_features(self) -> ClimateEntityFeature: features |= ClimateEntityFeature.FAN_MODE return features + @property + def temperature_unit(self) -> str: + """Return the temperature unit based on the thermostat's SCALE setting.""" + data = self._thermostat_data + if data is None: + return UnitOfTemperature.CELSIUS # Default per HA conventions + if data.get(CONTROL4_SCALE) == "FAHRENHEIT": + return UnitOfTemperature.FAHRENHEIT + return UnitOfTemperature.CELSIUS + + @property + def _cool_setpoint(self) -> float | None: + """Return the cooling setpoint from the appropriate variable.""" + data = self._thermostat_data + if data is None: + return None + if self.temperature_unit == UnitOfTemperature.CELSIUS: + return data.get(CONTROL4_COOL_SETPOINT_C) + return data.get(CONTROL4_COOL_SETPOINT_F) + + @property + def _heat_setpoint(self) -> float | None: + """Return the heating setpoint from the appropriate variable.""" + data = self._thermostat_data + if data is None: + return None + if self.temperature_unit == UnitOfTemperature.CELSIUS: + return data.get(CONTROL4_HEAT_SETPOINT_C) + return data.get(CONTROL4_HEAT_SETPOINT_F) + @property def current_temperature(self) -> float | None: """Return the current temperature.""" data = self._thermostat_data if data is None: return None - return data.get(CONTROL4_CURRENT_TEMPERATURE) + if self.temperature_unit == UnitOfTemperature.CELSIUS: + return data.get(CONTROL4_CURRENT_TEMPERATURE_C) + return data.get(CONTROL4_CURRENT_TEMPERATURE_F) @property def current_humidity(self) -> int | None: @@ -248,8 +293,14 @@ def hvac_action(self) -> HVACAction | None: c4_state = data.get(CONTROL4_HVAC_STATE) if c4_state is None: return None - # Convert state to lowercase for mapping action = C4_TO_HA_HVAC_ACTION.get(str(c4_state).lower()) + # Substring match for multi-stage systems that report + # e.g. "Stage 1 Heat", "Stage 2 Cool" + if action is None: + if "heat" in str(c4_state).lower(): + action = HVACAction.HEATING + elif "cool" in str(c4_state).lower(): + action = HVACAction.COOLING if action is None: _LOGGER.debug("Unknown HVAC state received from Control4: %s", c4_state) return action @@ -257,34 +308,25 @@ def hvac_action(self) -> HVACAction | None: @property def target_temperature(self) -> float | None: """Return the target temperature.""" - data = self._thermostat_data - if data is None: - return None hvac_mode = self.hvac_mode if hvac_mode == HVACMode.COOL: - return data.get(CONTROL4_COOL_SETPOINT) + return self._cool_setpoint if hvac_mode == HVACMode.HEAT: - return data.get(CONTROL4_HEAT_SETPOINT) + return self._heat_setpoint return None @property def target_temperature_high(self) -> float | None: """Return the high target temperature for auto mode.""" - data = self._thermostat_data - if data is None: - return None if self.hvac_mode == HVACMode.HEAT_COOL: - return data.get(CONTROL4_COOL_SETPOINT) + return self._cool_setpoint return None @property def target_temperature_low(self) -> float | None: """Return the low target temperature for auto mode.""" - data = self._thermostat_data - if data is None: - return None if self.hvac_mode == HVACMode.HEAT_COOL: - return data.get(CONTROL4_HEAT_SETPOINT) + return self._heat_setpoint return None @property @@ -326,15 +368,27 @@ async def async_set_temperature(self, **kwargs: Any) -> None: # Handle temperature range for auto mode if self.hvac_mode == HVACMode.HEAT_COOL: if low_temp is not None: - await c4_climate.setHeatSetpointF(low_temp) + if self.temperature_unit == UnitOfTemperature.CELSIUS: + await c4_climate.setHeatSetpointC(low_temp) + else: + await c4_climate.setHeatSetpointF(low_temp) if high_temp is not None: - await c4_climate.setCoolSetpointF(high_temp) + if self.temperature_unit == UnitOfTemperature.CELSIUS: + await c4_climate.setCoolSetpointC(high_temp) + else: + await c4_climate.setCoolSetpointF(high_temp) # Handle single temperature setpoint elif temp is not None: if self.hvac_mode == HVACMode.COOL: - await c4_climate.setCoolSetpointF(temp) + if self.temperature_unit == UnitOfTemperature.CELSIUS: + await c4_climate.setCoolSetpointC(temp) + else: + await c4_climate.setCoolSetpointF(temp) elif self.hvac_mode == HVACMode.HEAT: - await c4_climate.setHeatSetpointF(temp) + if self.temperature_unit == UnitOfTemperature.CELSIUS: + await c4_climate.setHeatSetpointC(temp) + else: + await c4_climate.setHeatSetpointF(temp) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/control4/light.py b/homeassistant/components/control4/light.py index 4a6c0cf1362eb4..2e9528063d130f 100644 --- a/homeassistant/components/control4/light.py +++ b/homeassistant/components/control4/light.py @@ -189,7 +189,7 @@ def _create_api_object(self): return C4Light(self.runtime_data.director, self._idx) @property - def is_on(self): + def is_on(self) -> bool: """Return whether this light is on or off.""" if self._is_dimmer: for var in CONTROL4_DIMMER_VARS: @@ -199,7 +199,7 @@ def is_on(self): return self.coordinator.data[self._idx][CONTROL4_NON_DIMMER_VAR] > 0 @property - def brightness(self): + def brightness(self) -> int | None: """Return the brightness of this light between 0..255.""" if self._is_dimmer: for var in CONTROL4_DIMMER_VARS: diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index eb52e2efe36e9f..b279d9b9943384 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -992,18 +992,11 @@ def _get_entity_name_tuples( continue context[attr] = state.attributes[attr] - if ( - entity := entity_registry.async_get(state.entity_id) - ) and entity.aliases: - for alias in entity.aliases: - alias = alias.strip() - if not alias: - continue - - yield (alias, alias, context) - - # Default name - yield (state.name, state.name, context) + entity_entry = entity_registry.async_get(state.entity_id) + for name in intent.async_get_entity_aliases( + self.hass, entity_entry, state=state + ): + yield (name, name, context) def _recognize_strict( self, diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py index 3ba2c45cbe5c68..86e18f3aff0119 100644 --- a/homeassistant/components/conversation/http.py +++ b/homeassistant/components/conversation/http.py @@ -48,6 +48,8 @@ def async_setup(hass: HomeAssistant) -> None: vol.Optional("conversation_id"): vol.Any(str, None), vol.Optional("language"): str, vol.Optional("agent_id"): agent_id_validator, + vol.Optional("device_id"): vol.Any(str, None), + vol.Optional("satellite_id"): vol.Any(str, None), } ) @websocket_api.async_response @@ -64,6 +66,8 @@ async def websocket_process( context=connection.context(msg), language=msg.get("language"), agent_id=msg.get("agent_id"), + device_id=msg.get("device_id"), + satellite_id=msg.get("satellite_id"), ) connection.send_result(msg["id"], result.as_dict()) @@ -248,6 +252,8 @@ class ConversationProcessView(http.HomeAssistantView): vol.Optional("conversation_id"): str, vol.Optional("language"): str, vol.Optional("agent_id"): agent_id_validator, + vol.Optional("device_id"): vol.Any(str, None), + vol.Optional("satellite_id"): vol.Any(str, None), } ) ) @@ -262,6 +268,8 @@ async def post(self, request: web.Request, data: dict[str, str]) -> web.Response context=self.context(request), language=data.get("language"), agent_id=data.get("agent_id"), + device_id=data.get("device_id"), + satellite_id=data.get("satellite_id"), ) return self.json(result.as_dict()) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index cfe6225d622935..a729e2c77c5d34 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.2.13"] + "requirements": ["hassil==3.5.0", "home-assistant-intents==2026.3.3"] } diff --git a/homeassistant/components/coolmaster/climate.py b/homeassistant/components/coolmaster/climate.py index d2425bc13ab407..f6017c95b43b06 100644 --- a/homeassistant/components/coolmaster/climate.py +++ b/homeassistant/components/coolmaster/climate.py @@ -107,17 +107,17 @@ def temperature_unit(self) -> str: return UnitOfTemperature.FAHRENHEIT @property - def current_temperature(self): + def current_temperature(self) -> float: """Return the current temperature.""" return self._unit.temperature @property - def target_temperature(self): + def target_temperature(self) -> float: """Return the temperature we are trying to reach.""" return self._unit.thermostat @property - def hvac_mode(self): + def hvac_mode(self) -> HVACMode: """Return hvac target hvac state.""" mode = self._unit.mode if not self._unit.is_on: @@ -126,7 +126,7 @@ def hvac_mode(self): return CM_TO_HA_STATE[mode] @property - def fan_mode(self): + def fan_mode(self) -> str: """Return the fan setting.""" # Normalize to lowercase for lookup, and pass unknown lowercase values through. @@ -145,7 +145,7 @@ def fan_mode(self): return CM_TO_HA_FAN[fan_speed_lower] @property - def fan_modes(self): + def fan_modes(self) -> list[str]: """Return the list of available fan modes.""" return FAN_MODES diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index ef50b244cf94d8..7dc9bd26d0372f 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -4,7 +4,6 @@ from collections.abc import Callable from datetime import timedelta -from enum import IntFlag, StrEnum import functools as ft import logging from typing import Any, final @@ -33,7 +32,21 @@ from homeassistant.loader import bind_hass from homeassistant.util.hass_dict import HassKey -from .const import DOMAIN, INTENT_CLOSE_COVER, INTENT_OPEN_COVER # noqa: F401 +from .condition import make_cover_is_closed_condition, make_cover_is_open_condition +from .const import ( + ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, + ATTR_IS_CLOSED, + ATTR_POSITION, + ATTR_TILT_POSITION, + DOMAIN, + INTENT_CLOSE_COVER, + INTENT_OPEN_COVER, + CoverDeviceClass, + CoverEntityFeature, + CoverState, +) +from .trigger import make_cover_closed_trigger, make_cover_opened_trigger _LOGGER = logging.getLogger(__name__) @@ -43,56 +56,35 @@ PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE SCAN_INTERVAL = timedelta(seconds=15) - -class CoverState(StrEnum): - """State of Cover entities.""" - - CLOSED = "closed" - CLOSING = "closing" - OPEN = "open" - OPENING = "opening" - - -class CoverDeviceClass(StrEnum): - """Device class for cover.""" - - # Refer to the cover dev docs for device class descriptions - AWNING = "awning" - BLIND = "blind" - CURTAIN = "curtain" - DAMPER = "damper" - DOOR = "door" - GARAGE = "garage" - GATE = "gate" - SHADE = "shade" - SHUTTER = "shutter" - WINDOW = "window" - - DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(CoverDeviceClass)) DEVICE_CLASSES = [cls.value for cls in CoverDeviceClass] - # mypy: disallow-any-generics -class CoverEntityFeature(IntFlag): - """Supported features of the cover entity.""" - - OPEN = 1 - CLOSE = 2 - SET_POSITION = 4 - STOP = 8 - OPEN_TILT = 16 - CLOSE_TILT = 32 - STOP_TILT = 64 - SET_TILT_POSITION = 128 - - -ATTR_CURRENT_POSITION = "current_position" -ATTR_CURRENT_TILT_POSITION = "current_tilt_position" -ATTR_POSITION = "position" -ATTR_TILT_POSITION = "tilt_position" +__all__ = [ + "ATTR_CURRENT_POSITION", + "ATTR_CURRENT_TILT_POSITION", + "ATTR_IS_CLOSED", + "ATTR_POSITION", + "ATTR_TILT_POSITION", + "DEVICE_CLASSES", + "DEVICE_CLASSES_SCHEMA", + "DOMAIN", + "INTENT_CLOSE_COVER", + "INTENT_OPEN_COVER", + "PLATFORM_SCHEMA", + "PLATFORM_SCHEMA_BASE", + "CoverDeviceClass", + "CoverEntity", + "CoverEntityDescription", + "CoverEntityFeature", + "CoverState", + "make_cover_closed_trigger", + "make_cover_is_closed_condition", + "make_cover_is_open_condition", + "make_cover_opened_trigger", +] @bind_hass @@ -267,7 +259,9 @@ def state(self) -> str | None: @property def state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - data = {} + data: dict[str, Any] = {} + + data[ATTR_IS_CLOSED] = self.is_closed if (current := self.current_cover_position) is not None: data[ATTR_CURRENT_POSITION] = current diff --git a/homeassistant/components/cover/condition.py b/homeassistant/components/cover/condition.py new file mode 100644 index 00000000000000..b662f794e5c7ba --- /dev/null +++ b/homeassistant/components/cover/condition.py @@ -0,0 +1,103 @@ +"""Provides conditions for covers.""" + +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant, State, split_entity_id +from homeassistant.helpers.condition import Condition, EntityConditionBase + +from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass +from .models import CoverDomainSpec + + +class CoverConditionBase(EntityConditionBase[CoverDomainSpec]): + """Base condition for cover state checks.""" + + def is_valid_state(self, entity_state: State) -> bool: + """Check if the state matches the expected cover state.""" + domain_spec = self._domain_specs[split_entity_id(entity_state.entity_id)[0]] + if domain_spec.value_source is not None: + return ( + entity_state.attributes.get(domain_spec.value_source) + == domain_spec.target_value + ) + return entity_state.state == domain_spec.target_value + + +def make_cover_is_open_condition( + *, device_classes: dict[str, str] +) -> type[CoverConditionBase]: + """Create a condition for cover is open.""" + + class CoverIsOpenCondition(CoverConditionBase): + """Condition for cover is open.""" + + _domain_specs = { + domain: CoverDomainSpec( + device_class=dc, + value_source=ATTR_IS_CLOSED if domain == DOMAIN else None, + target_value=False if domain == DOMAIN else STATE_ON, + ) + for domain, dc in device_classes.items() + } + + return CoverIsOpenCondition + + +def make_cover_is_closed_condition( + *, device_classes: dict[str, str] +) -> type[CoverConditionBase]: + """Create a condition for cover is closed.""" + + class CoverIsClosedCondition(CoverConditionBase): + """Condition for cover is closed.""" + + _domain_specs = { + domain: CoverDomainSpec( + device_class=dc, + value_source=ATTR_IS_CLOSED if domain == DOMAIN else None, + target_value=True if domain == DOMAIN else STATE_OFF, + ) + for domain, dc in device_classes.items() + } + + return CoverIsClosedCondition + + +DEVICE_CLASSES_AWNING: dict[str, str] = {DOMAIN: CoverDeviceClass.AWNING} +DEVICE_CLASSES_BLIND: dict[str, str] = {DOMAIN: CoverDeviceClass.BLIND} +DEVICE_CLASSES_CURTAIN: dict[str, str] = {DOMAIN: CoverDeviceClass.CURTAIN} +DEVICE_CLASSES_SHADE: dict[str, str] = {DOMAIN: CoverDeviceClass.SHADE} +DEVICE_CLASSES_SHUTTER: dict[str, str] = {DOMAIN: CoverDeviceClass.SHUTTER} + +CONDITIONS: dict[str, type[Condition]] = { + "awning_is_closed": make_cover_is_closed_condition( + device_classes=DEVICE_CLASSES_AWNING + ), + "awning_is_open": make_cover_is_open_condition( + device_classes=DEVICE_CLASSES_AWNING + ), + "blind_is_closed": make_cover_is_closed_condition( + device_classes=DEVICE_CLASSES_BLIND + ), + "blind_is_open": make_cover_is_open_condition(device_classes=DEVICE_CLASSES_BLIND), + "curtain_is_closed": make_cover_is_closed_condition( + device_classes=DEVICE_CLASSES_CURTAIN + ), + "curtain_is_open": make_cover_is_open_condition( + device_classes=DEVICE_CLASSES_CURTAIN + ), + "shade_is_closed": make_cover_is_closed_condition( + device_classes=DEVICE_CLASSES_SHADE + ), + "shade_is_open": make_cover_is_open_condition(device_classes=DEVICE_CLASSES_SHADE), + "shutter_is_closed": make_cover_is_closed_condition( + device_classes=DEVICE_CLASSES_SHUTTER + ), + "shutter_is_open": make_cover_is_open_condition( + device_classes=DEVICE_CLASSES_SHUTTER + ), +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the conditions for covers.""" + return CONDITIONS diff --git a/homeassistant/components/cover/conditions.yaml b/homeassistant/components/cover/conditions.yaml new file mode 100644 index 00000000000000..075f3a926bc547 --- /dev/null +++ b/homeassistant/components/cover/conditions.yaml @@ -0,0 +1,80 @@ +.condition_common_fields: &condition_common_fields + behavior: + required: true + default: any + selector: + select: + translation_key: condition_behavior + options: + - all + - any + +awning_is_closed: + fields: *condition_common_fields + target: + entity: + - domain: cover + device_class: awning + +awning_is_open: + fields: *condition_common_fields + target: + entity: + - domain: cover + device_class: awning + +blind_is_closed: + fields: *condition_common_fields + target: + entity: + - domain: cover + device_class: blind + +blind_is_open: + fields: *condition_common_fields + target: + entity: + - domain: cover + device_class: blind + +curtain_is_closed: + fields: *condition_common_fields + target: + entity: + - domain: cover + device_class: curtain + +curtain_is_open: + fields: *condition_common_fields + target: + entity: + - domain: cover + device_class: curtain + +shade_is_closed: + fields: *condition_common_fields + target: + entity: + - domain: cover + device_class: shade + +shade_is_open: + fields: *condition_common_fields + target: + entity: + - domain: cover + device_class: shade + +shutter_is_closed: + fields: *condition_common_fields + target: + entity: + - domain: cover + device_class: shutter + +shutter_is_open: + fields: *condition_common_fields + target: + entity: + - domain: cover + device_class: shutter diff --git a/homeassistant/components/cover/const.py b/homeassistant/components/cover/const.py index e9bbf81e5f51bb..c73b32998c59a0 100644 --- a/homeassistant/components/cover/const.py +++ b/homeassistant/components/cover/const.py @@ -1,6 +1,52 @@ """Constants for cover entity platform.""" +from enum import IntFlag, StrEnum + DOMAIN = "cover" +ATTR_CURRENT_POSITION = "current_position" +ATTR_CURRENT_TILT_POSITION = "current_tilt_position" +ATTR_IS_CLOSED = "is_closed" +ATTR_POSITION = "position" +ATTR_TILT_POSITION = "tilt_position" + INTENT_OPEN_COVER = "HassOpenCover" INTENT_CLOSE_COVER = "HassCloseCover" + + +class CoverEntityFeature(IntFlag): + """Supported features of the cover entity.""" + + OPEN = 1 + CLOSE = 2 + SET_POSITION = 4 + STOP = 8 + OPEN_TILT = 16 + CLOSE_TILT = 32 + STOP_TILT = 64 + SET_TILT_POSITION = 128 + + +class CoverState(StrEnum): + """State of Cover entities.""" + + CLOSED = "closed" + CLOSING = "closing" + OPEN = "open" + OPENING = "opening" + + +class CoverDeviceClass(StrEnum): + """Device class for cover.""" + + # Refer to the cover dev docs for device class descriptions + AWNING = "awning" + BLIND = "blind" + CURTAIN = "curtain" + DAMPER = "damper" + DOOR = "door" + GARAGE = "garage" + GATE = "gate" + SHADE = "shade" + SHUTTER = "shutter" + WINDOW = "window" diff --git a/homeassistant/components/cover/icons.json b/homeassistant/components/cover/icons.json index 91775fe634dbec..fcc6df4cb70888 100644 --- a/homeassistant/components/cover/icons.json +++ b/homeassistant/components/cover/icons.json @@ -1,4 +1,36 @@ { + "conditions": { + "awning_is_closed": { + "condition": "mdi:storefront-outline" + }, + "awning_is_open": { + "condition": "mdi:storefront-outline" + }, + "blind_is_closed": { + "condition": "mdi:blinds-horizontal-closed" + }, + "blind_is_open": { + "condition": "mdi:blinds-horizontal" + }, + "curtain_is_closed": { + "condition": "mdi:curtains-closed" + }, + "curtain_is_open": { + "condition": "mdi:curtains" + }, + "shade_is_closed": { + "condition": "mdi:roller-shade-closed" + }, + "shade_is_open": { + "condition": "mdi:roller-shade" + }, + "shutter_is_closed": { + "condition": "mdi:window-shutter" + }, + "shutter_is_open": { + "condition": "mdi:window-shutter-open" + } + }, "entity_component": { "_": { "default": "mdi:window-open", @@ -108,5 +140,37 @@ "toggle_cover_tilt": { "service": "mdi:arrow-top-right-bottom-left" } + }, + "triggers": { + "awning_closed": { + "trigger": "mdi:storefront-outline" + }, + "awning_opened": { + "trigger": "mdi:storefront-outline" + }, + "blind_closed": { + "trigger": "mdi:blinds-horizontal-closed" + }, + "blind_opened": { + "trigger": "mdi:blinds-horizontal" + }, + "curtain_closed": { + "trigger": "mdi:curtains-closed" + }, + "curtain_opened": { + "trigger": "mdi:curtains" + }, + "shade_closed": { + "trigger": "mdi:roller-shade-closed" + }, + "shade_opened": { + "trigger": "mdi:roller-shade" + }, + "shutter_closed": { + "trigger": "mdi:window-shutter" + }, + "shutter_opened": { + "trigger": "mdi:window-shutter-open" + } } } diff --git a/homeassistant/components/cover/intent.py b/homeassistant/components/cover/intent.py index dfc7d0f69a072e..a54cfd98eacaa3 100644 --- a/homeassistant/components/cover/intent.py +++ b/homeassistant/components/cover/intent.py @@ -15,7 +15,6 @@ async def async_setup_intents(hass: HomeAssistant) -> None: INTENT_OPEN_COVER, DOMAIN, SERVICE_OPEN_COVER, - "Opening {}", description="Opens a cover", platforms={DOMAIN}, device_classes={CoverDeviceClass}, @@ -27,7 +26,6 @@ async def async_setup_intents(hass: HomeAssistant) -> None: INTENT_CLOSE_COVER, DOMAIN, SERVICE_CLOSE_COVER, - "Closing {}", description="Closes a cover", platforms={DOMAIN}, device_classes={CoverDeviceClass}, diff --git a/homeassistant/components/cover/models.py b/homeassistant/components/cover/models.py new file mode 100644 index 00000000000000..9704f361239284 --- /dev/null +++ b/homeassistant/components/cover/models.py @@ -0,0 +1,12 @@ +"""Data models for the cover integration.""" + +from dataclasses import dataclass + +from homeassistant.helpers.automation import DomainSpec + + +@dataclass(frozen=True, slots=True) +class CoverDomainSpec(DomainSpec): + """DomainSpec with a target value for comparison.""" + + target_value: str | bool | None = None diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json index f0d42685e85e4e..143cace29a23ce 100644 --- a/homeassistant/components/cover/strings.json +++ b/homeassistant/components/cover/strings.json @@ -1,4 +1,112 @@ { + "common": { + "condition_behavior_description": "How the state should match on the targeted covers.", + "condition_behavior_name": "Behavior", + "trigger_behavior_description": "The behavior of the targeted covers to trigger on.", + "trigger_behavior_name": "Behavior" + }, + "conditions": { + "awning_is_closed": { + "description": "Tests if one or more awnings are closed.", + "fields": { + "behavior": { + "description": "[%key:component::cover::common::condition_behavior_description%]", + "name": "[%key:component::cover::common::condition_behavior_name%]" + } + }, + "name": "Awning is closed" + }, + "awning_is_open": { + "description": "Tests if one or more awnings are open.", + "fields": { + "behavior": { + "description": "[%key:component::cover::common::condition_behavior_description%]", + "name": "[%key:component::cover::common::condition_behavior_name%]" + } + }, + "name": "Awning is open" + }, + "blind_is_closed": { + "description": "Tests if one or more blinds are closed.", + "fields": { + "behavior": { + "description": "[%key:component::cover::common::condition_behavior_description%]", + "name": "[%key:component::cover::common::condition_behavior_name%]" + } + }, + "name": "Blind is closed" + }, + "blind_is_open": { + "description": "Tests if one or more blinds are open.", + "fields": { + "behavior": { + "description": "[%key:component::cover::common::condition_behavior_description%]", + "name": "[%key:component::cover::common::condition_behavior_name%]" + } + }, + "name": "Blind is open" + }, + "curtain_is_closed": { + "description": "Tests if one or more curtains are closed.", + "fields": { + "behavior": { + "description": "[%key:component::cover::common::condition_behavior_description%]", + "name": "[%key:component::cover::common::condition_behavior_name%]" + } + }, + "name": "Curtain is closed" + }, + "curtain_is_open": { + "description": "Tests if one or more curtains are open.", + "fields": { + "behavior": { + "description": "[%key:component::cover::common::condition_behavior_description%]", + "name": "[%key:component::cover::common::condition_behavior_name%]" + } + }, + "name": "Curtain is open" + }, + "shade_is_closed": { + "description": "Tests if one or more shades are closed.", + "fields": { + "behavior": { + "description": "[%key:component::cover::common::condition_behavior_description%]", + "name": "[%key:component::cover::common::condition_behavior_name%]" + } + }, + "name": "Shade is closed" + }, + "shade_is_open": { + "description": "Tests if one or more shades are open.", + "fields": { + "behavior": { + "description": "[%key:component::cover::common::condition_behavior_description%]", + "name": "[%key:component::cover::common::condition_behavior_name%]" + } + }, + "name": "Shade is open" + }, + "shutter_is_closed": { + "description": "Tests if one or more shutters are closed.", + "fields": { + "behavior": { + "description": "[%key:component::cover::common::condition_behavior_description%]", + "name": "[%key:component::cover::common::condition_behavior_name%]" + } + }, + "name": "Shutter is closed" + }, + "shutter_is_open": { + "description": "Tests if one or more shutters are open.", + "fields": { + "behavior": { + "description": "[%key:component::cover::common::condition_behavior_description%]", + "name": "[%key:component::cover::common::condition_behavior_name%]" + } + }, + "name": "Shutter is open" + } + }, "device_automation": { "action_type": { "close": "Close {entity_name}", @@ -82,6 +190,21 @@ "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.", @@ -136,5 +259,107 @@ "name": "Toggle tilt" } }, - "title": "Cover" + "title": "Cover", + "triggers": { + "awning_closed": { + "description": "Triggers after one or more awnings close.", + "fields": { + "behavior": { + "description": "[%key:component::cover::common::trigger_behavior_description%]", + "name": "[%key:component::cover::common::trigger_behavior_name%]" + } + }, + "name": "Awning closed" + }, + "awning_opened": { + "description": "Triggers after one or more awnings open.", + "fields": { + "behavior": { + "description": "[%key:component::cover::common::trigger_behavior_description%]", + "name": "[%key:component::cover::common::trigger_behavior_name%]" + } + }, + "name": "Awning opened" + }, + "blind_closed": { + "description": "Triggers after one or more blinds close.", + "fields": { + "behavior": { + "description": "[%key:component::cover::common::trigger_behavior_description%]", + "name": "[%key:component::cover::common::trigger_behavior_name%]" + } + }, + "name": "Blind closed" + }, + "blind_opened": { + "description": "Triggers after one or more blinds open.", + "fields": { + "behavior": { + "description": "[%key:component::cover::common::trigger_behavior_description%]", + "name": "[%key:component::cover::common::trigger_behavior_name%]" + } + }, + "name": "Blind opened" + }, + "curtain_closed": { + "description": "Triggers after one or more curtains close.", + "fields": { + "behavior": { + "description": "[%key:component::cover::common::trigger_behavior_description%]", + "name": "[%key:component::cover::common::trigger_behavior_name%]" + } + }, + "name": "Curtain closed" + }, + "curtain_opened": { + "description": "Triggers after one or more curtains open.", + "fields": { + "behavior": { + "description": "[%key:component::cover::common::trigger_behavior_description%]", + "name": "[%key:component::cover::common::trigger_behavior_name%]" + } + }, + "name": "Curtain opened" + }, + "shade_closed": { + "description": "Triggers after one or more shades close.", + "fields": { + "behavior": { + "description": "[%key:component::cover::common::trigger_behavior_description%]", + "name": "[%key:component::cover::common::trigger_behavior_name%]" + } + }, + "name": "Shade closed" + }, + "shade_opened": { + "description": "Triggers after one or more shades open.", + "fields": { + "behavior": { + "description": "[%key:component::cover::common::trigger_behavior_description%]", + "name": "[%key:component::cover::common::trigger_behavior_name%]" + } + }, + "name": "Shade opened" + }, + "shutter_closed": { + "description": "Triggers after one or more shutters close.", + "fields": { + "behavior": { + "description": "[%key:component::cover::common::trigger_behavior_description%]", + "name": "[%key:component::cover::common::trigger_behavior_name%]" + } + }, + "name": "Shutter closed" + }, + "shutter_opened": { + "description": "Triggers after one or more shutters open.", + "fields": { + "behavior": { + "description": "[%key:component::cover::common::trigger_behavior_description%]", + "name": "[%key:component::cover::common::trigger_behavior_name%]" + } + }, + "name": "Shutter opened" + } + } } diff --git a/homeassistant/components/cover/trigger.py b/homeassistant/components/cover/trigger.py new file mode 100644 index 00000000000000..149a3e01cc0bd9 --- /dev/null +++ b/homeassistant/components/cover/trigger.py @@ -0,0 +1,99 @@ +"""Provides triggers for covers.""" + +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.trigger import EntityTriggerBase, Trigger + +from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass +from .models import CoverDomainSpec + + +class CoverTriggerBase(EntityTriggerBase[CoverDomainSpec]): + """Base trigger for cover state changes.""" + + def _get_value(self, state: State) -> str | bool | None: + """Extract the relevant value from state based on domain spec.""" + domain_spec = self._domain_specs[state.domain] + if domain_spec.value_source is not None: + return state.attributes.get(domain_spec.value_source) + return state.state + + def is_valid_state(self, state: State) -> bool: + """Check if the state matches the target cover state.""" + domain_spec = self._domain_specs[state.domain] + return self._get_value(state) == domain_spec.target_value + + def is_valid_transition(self, from_state: State, to_state: State) -> bool: + """Check if the transition is valid for a cover state change.""" + if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + return False + if (from_value := self._get_value(from_state)) is None: + return False + return from_value != self._get_value(to_state) + + +def make_cover_opened_trigger( + *, device_classes: dict[str, str] +) -> type[CoverTriggerBase]: + """Create a trigger cover_opened.""" + + class CoverOpenedTrigger(CoverTriggerBase): + """Trigger for cover opened state changes.""" + + _domain_specs = { + domain: CoverDomainSpec( + device_class=dc, + value_source=ATTR_IS_CLOSED if domain == DOMAIN else None, + target_value=False if domain == DOMAIN else STATE_ON, + ) + for domain, dc in device_classes.items() + } + + return CoverOpenedTrigger + + +def make_cover_closed_trigger( + *, device_classes: dict[str, str] +) -> type[CoverTriggerBase]: + """Create a trigger cover_closed.""" + + class CoverClosedTrigger(CoverTriggerBase): + """Trigger for cover closed state changes.""" + + _domain_specs = { + domain: CoverDomainSpec( + device_class=dc, + value_source=ATTR_IS_CLOSED if domain == DOMAIN else None, + target_value=True if domain == DOMAIN else STATE_OFF, + ) + for domain, dc in device_classes.items() + } + + return CoverClosedTrigger + + +# Concrete triggers for cover device classes (cover-only, no binary sensor) + +DEVICE_CLASSES_AWNING: dict[str, str] = {DOMAIN: CoverDeviceClass.AWNING} +DEVICE_CLASSES_BLIND: dict[str, str] = {DOMAIN: CoverDeviceClass.BLIND} +DEVICE_CLASSES_CURTAIN: dict[str, str] = {DOMAIN: CoverDeviceClass.CURTAIN} +DEVICE_CLASSES_SHADE: dict[str, str] = {DOMAIN: CoverDeviceClass.SHADE} +DEVICE_CLASSES_SHUTTER: dict[str, str] = {DOMAIN: CoverDeviceClass.SHUTTER} + +TRIGGERS: dict[str, type[Trigger]] = { + "awning_opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_AWNING), + "awning_closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_AWNING), + "blind_opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_BLIND), + "blind_closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_BLIND), + "curtain_opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_CURTAIN), + "curtain_closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_CURTAIN), + "shade_opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_SHADE), + "shade_closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_SHADE), + "shutter_opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_SHUTTER), + "shutter_closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_SHUTTER), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for covers.""" + return TRIGGERS diff --git a/homeassistant/components/cover/triggers.yaml b/homeassistant/components/cover/triggers.yaml new file mode 100644 index 00000000000000..4b9d0a054dc9c8 --- /dev/null +++ b/homeassistant/components/cover/triggers.yaml @@ -0,0 +1,81 @@ +.trigger_common_fields: &trigger_common_fields + behavior: + required: true + default: any + selector: + select: + translation_key: trigger_behavior + options: + - first + - last + - any + +awning_closed: + fields: *trigger_common_fields + target: + entity: + - domain: cover + device_class: awning + +awning_opened: + fields: *trigger_common_fields + target: + entity: + - domain: cover + device_class: awning + +blind_closed: + fields: *trigger_common_fields + target: + entity: + - domain: cover + device_class: blind + +blind_opened: + fields: *trigger_common_fields + target: + entity: + - domain: cover + device_class: blind + +curtain_closed: + fields: *trigger_common_fields + target: + entity: + - domain: cover + device_class: curtain + +curtain_opened: + fields: *trigger_common_fields + target: + entity: + - domain: cover + device_class: curtain + +shade_closed: + fields: *trigger_common_fields + target: + entity: + - domain: cover + device_class: shade + +shade_opened: + fields: *trigger_common_fields + target: + entity: + - domain: cover + device_class: shade + +shutter_closed: + fields: *trigger_common_fields + target: + entity: + - domain: cover + device_class: shutter + +shutter_opened: + fields: *trigger_common_fields + target: + entity: + - domain: cover + device_class: shutter diff --git a/homeassistant/components/currencylayer/sensor.py b/homeassistant/components/currencylayer/sensor.py index 7c985b12ba4733..832a856f51a973 100644 --- a/homeassistant/components/currencylayer/sensor.py +++ b/homeassistant/components/currencylayer/sensor.py @@ -65,33 +65,18 @@ class CurrencylayerSensor(SensorEntity): _attr_attribution = "Data provided by currencylayer.com" _attr_icon = "mdi:currency" - def __init__(self, rest, base, quote): + def __init__(self, rest: CurrencylayerData, base: str, quote: str) -> None: """Initialize the sensor.""" self.rest = rest - self._quote = quote - self._base = base - self._state = None - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._quote - - @property - def name(self): - """Return the name of the sensor.""" - return self._base - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state + self._attr_name = base + self._attr_native_unit_of_measurement = quote + self._key = f"{base}{quote}" def update(self) -> None: """Update current date.""" self.rest.update() if (value := self.rest.data) is not None: - self._state = round(value[f"{self._base}{self._quote}"], 4) + self._attr_native_value = round(value[self._key], 4) class CurrencylayerData: diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py index 648a65c0d30be4..d9917c3cfe629f 100644 --- a/homeassistant/components/daikin/climate.py +++ b/homeassistant/components/daikin/climate.py @@ -2,9 +2,12 @@ from __future__ import annotations +from collections.abc import Sequence import logging from typing import Any +from pydaikin.daikin_base import Appliance + from homeassistant.components.climate import ( ATTR_FAN_MODE, ATTR_HVAC_MODE, @@ -21,6 +24,7 @@ ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( @@ -29,12 +33,19 @@ ATTR_STATE_OFF, ATTR_STATE_ON, ATTR_TARGET_TEMPERATURE, + DOMAIN, + ZONE_NAME_UNCONFIGURED, ) from .coordinator import DaikinConfigEntry, DaikinCoordinator from .entity import DaikinEntity _LOGGER = logging.getLogger(__name__) +type DaikinZone = Sequence[str | int] + +DAIKIN_ZONE_TEMP_HEAT = "lztemp_h" +DAIKIN_ZONE_TEMP_COOL = "lztemp_c" + HA_STATE_TO_DAIKIN = { HVACMode.FAN_ONLY: "fan", @@ -78,6 +89,71 @@ } DAIKIN_ATTR_ADVANCED = "adv" +ZONE_TEMPERATURE_WINDOW = 2 + + +def _zone_error( + translation_key: str, placeholders: dict[str, str] | None = None +) -> HomeAssistantError: + """Return a Home Assistant error with Daikin translation info.""" + return HomeAssistantError( + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders=placeholders, + ) + + +def _zone_is_configured(zone: DaikinZone) -> bool: + """Return True if the Daikin zone represents a configured zone.""" + if not zone: + return False + return zone[0] != ZONE_NAME_UNCONFIGURED + + +def _zone_temperature_lists(device: Appliance) -> tuple[list[str], list[str]]: + """Return the decoded zone temperature lists.""" + values = device.values + if DAIKIN_ZONE_TEMP_HEAT not in values or DAIKIN_ZONE_TEMP_COOL not in values: + return ([], []) + + heating = device.represent(DAIKIN_ZONE_TEMP_HEAT)[1] + cooling = device.represent(DAIKIN_ZONE_TEMP_COOL)[1] + return (list(heating or []), list(cooling or [])) + + +def _supports_zone_temperature_control(device: Appliance) -> bool: + """Return True if the device exposes zone temperature settings.""" + zones = device.zones + if not zones: + return False + heating, cooling = _zone_temperature_lists(device) + return bool( + heating + and cooling + and len(heating) >= len(zones) + and len(cooling) >= len(zones) + ) + + +def _system_target_temperature(device: Appliance) -> float | None: + """Return the system target temperature when available.""" + target = device.target_temperature + if target is None: + return None + try: + return float(target) + except TypeError, ValueError: + return None + + +def _zone_temperature_from_list(values: list[str], zone_id: int) -> float | None: + """Return the parsed temperature for a zone from a Daikin list.""" + if zone_id >= len(values): + return None + try: + return float(values[zone_id]) + except TypeError, ValueError: + return None async def async_setup_entry( @@ -86,8 +162,16 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Daikin climate based on config_entry.""" - daikin_api = entry.runtime_data - async_add_entities([DaikinClimate(daikin_api)]) + coordinator = entry.runtime_data + entities: list[ClimateEntity] = [DaikinClimate(coordinator)] + if _supports_zone_temperature_control(coordinator.device): + zones = coordinator.device.zones or [] + entities.extend( + DaikinZoneClimate(coordinator, zone_id) + for zone_id, zone in enumerate(zones) + if _zone_is_configured(zone) + ) + async_add_entities(entities) def format_target_temperature(target_temperature: float) -> str: @@ -284,3 +368,130 @@ async def async_turn_off(self) -> None: {HA_ATTR_TO_DAIKIN[ATTR_HVAC_MODE]: HA_STATE_TO_DAIKIN[HVACMode.OFF]} ) await self.coordinator.async_refresh() + + +class DaikinZoneClimate(DaikinEntity, ClimateEntity): + """Representation of a Daikin zone temperature controller.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_has_entity_name = True + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_target_temperature_step = 1 + + def __init__(self, coordinator: DaikinCoordinator, zone_id: int) -> None: + """Initialize the zone climate entity.""" + super().__init__(coordinator) + self._zone_id = zone_id + self._attr_unique_id = f"{self.device.mac}-zone{zone_id}-temperature" + zone_name = self.device.zones[self._zone_id][0] + self._attr_name = f"{zone_name} temperature" + + @property + def hvac_modes(self) -> list[HVACMode]: + """Return the hvac modes (mirrors the main unit).""" + return [self.hvac_mode] + + @property + def hvac_mode(self) -> HVACMode: + """Return the current HVAC mode.""" + daikin_mode = self.device.represent(HA_ATTR_TO_DAIKIN[ATTR_HVAC_MODE])[1] + return DAIKIN_TO_HA_STATE.get(daikin_mode, HVACMode.HEAT_COOL) + + @property + def hvac_action(self) -> HVACAction | None: + """Return the current HVAC action.""" + return HA_STATE_TO_CURRENT_HVAC.get(self.hvac_mode) + + @property + def target_temperature(self) -> float | None: + """Return the zone target temperature for the active mode.""" + heating, cooling = _zone_temperature_lists(self.device) + mode = self.hvac_mode + if mode == HVACMode.HEAT: + return _zone_temperature_from_list(heating, self._zone_id) + if mode == HVACMode.COOL: + return _zone_temperature_from_list(cooling, self._zone_id) + return None + + @property + def min_temp(self) -> float: + """Return the minimum selectable temperature.""" + target = _system_target_temperature(self.device) + if target is None: + return super().min_temp + return target - ZONE_TEMPERATURE_WINDOW + + @property + def max_temp(self) -> float: + """Return the maximum selectable temperature.""" + target = _system_target_temperature(self.device) + if target is None: + return super().max_temp + return target + ZONE_TEMPERATURE_WINDOW + + @property + def available(self) -> bool: + """Return if the entity is available.""" + return ( + super().available + and _supports_zone_temperature_control(self.device) + and _system_target_temperature(self.device) is not None + ) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return additional metadata.""" + return {"zone_id": self._zone_id} + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the zone temperature.""" + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="zone_temperature_missing", + ) + zones = self.device.zones + if not zones or not _supports_zone_temperature_control(self.device): + raise _zone_error("zone_parameters_unavailable") + + try: + zone = zones[self._zone_id] + except (IndexError, TypeError) as err: + raise _zone_error( + "zone_missing", + { + "zone_id": str(self._zone_id), + "max_zone": str(len(zones) - 1), + }, + ) from err + + if not _zone_is_configured(zone): + raise _zone_error("zone_inactive", {"zone_id": str(self._zone_id)}) + + temperature_value = float(temperature) + target = _system_target_temperature(self.device) + if target is None: + raise _zone_error("zone_parameters_unavailable") + + mode = self.hvac_mode + if mode == HVACMode.HEAT: + zone_key = DAIKIN_ZONE_TEMP_HEAT + elif mode == HVACMode.COOL: + zone_key = DAIKIN_ZONE_TEMP_COOL + else: + raise _zone_error("zone_hvac_mode_unsupported") + + zone_value = str(round(temperature_value)) + try: + await self.device.set_zone(self._zone_id, zone_key, zone_value) + except (AttributeError, KeyError, NotImplementedError, TypeError) as err: + raise _zone_error("zone_set_failed") from err + + await self.coordinator.async_request_refresh() + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Disallow changing HVAC mode via zone climate.""" + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="zone_hvac_read_only", + ) diff --git a/homeassistant/components/daikin/const.py b/homeassistant/components/daikin/const.py index f093569ea54df7..27f0b9ba57d328 100644 --- a/homeassistant/components/daikin/const.py +++ b/homeassistant/components/daikin/const.py @@ -24,4 +24,6 @@ KEY_MAC = "mac" KEY_IP = "ip" +ZONE_NAME_UNCONFIGURED = "-" + TIMEOUT_SEC = 120 diff --git a/homeassistant/components/daikin/strings.json b/homeassistant/components/daikin/strings.json index 53645b1e7bd41c..b3326454d375b4 100644 --- a/homeassistant/components/daikin/strings.json +++ b/homeassistant/components/daikin/strings.json @@ -57,5 +57,28 @@ "name": "Power" } } + }, + "exceptions": { + "zone_hvac_mode_unsupported": { + "message": "Zone temperature can only be changed when the main climate mode is heat or cool." + }, + "zone_hvac_read_only": { + "message": "Zone HVAC mode is controlled by the main climate entity." + }, + "zone_inactive": { + "message": "Zone {zone_id} is not active. Enable the zone on your Daikin device first." + }, + "zone_missing": { + "message": "Zone {zone_id} does not exist. Available zones are 0-{max_zone}." + }, + "zone_parameters_unavailable": { + "message": "This device does not expose the required zone temperature parameters." + }, + "zone_set_failed": { + "message": "Failed to set zone temperature. The device may not support this operation." + }, + "zone_temperature_missing": { + "message": "Provide a temperature value when adjusting a zone." + } } } diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py index 20a56ac321cd6e..20d27e7d3ea372 100644 --- a/homeassistant/components/daikin/switch.py +++ b/homeassistant/components/daikin/switch.py @@ -8,6 +8,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import ZONE_NAME_UNCONFIGURED from .coordinator import DaikinConfigEntry, DaikinCoordinator from .entity import DaikinEntity @@ -28,7 +29,7 @@ async def async_setup_entry( switches.extend( DaikinZoneSwitch(daikin_api, zone_id) for zone_id, zone in enumerate(zones) - if zone[0] != "-" + if zone[0] != ZONE_NAME_UNCONFIGURED ) if daikin_api.device.support_advanced_modes: # It isn't possible to find out from the API responses if a specific diff --git a/homeassistant/components/danfoss_air/switch.py b/homeassistant/components/danfoss_air/switch.py index 5e7c5728d81b25..c30dc3fac83ddf 100644 --- a/homeassistant/components/danfoss_air/switch.py +++ b/homeassistant/components/danfoss_air/switch.py @@ -59,21 +59,10 @@ class DanfossAir(SwitchEntity): def __init__(self, data, name, state_command, on_command, off_command): """Initialize the switch.""" self._data = data - self._name = name + self._attr_name = name self._state_command = state_command self._on_command = on_command self._off_command = off_command - self._state = None - - @property - def name(self): - """Return the name of the switch.""" - return self._name - - @property - def is_on(self): - """Return true if switch is on.""" - return self._state def turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" @@ -89,6 +78,6 @@ def update(self) -> None: """Update the switch's state.""" self._data.update() - self._state = self._data.get_value(self._state_command) - if self._state is None: + self._attr_is_on = self._data.get_value(self._state_command) + if self._attr_is_on is None: _LOGGER.debug("Could not get data for %s", self._state_command) diff --git a/homeassistant/components/decora_wifi/light.py b/homeassistant/components/decora_wifi/light.py index 4efc06a11ffa7d..4ec9a1e4246dac 100644 --- a/homeassistant/components/decora_wifi/light.py +++ b/homeassistant/components/decora_wifi/light.py @@ -132,12 +132,12 @@ def unique_id(self): return self._switch.serial @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of the dimmer switch.""" return int(self._switch.brightness * 255 / 100) @property - def is_on(self): + def is_on(self) -> bool: """Return true if switch is on.""" return self._switch.power == "ON" diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index 0c001921c7a517..c65cdd12becd88 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -139,18 +139,6 @@ def mute_volume(self, mute: bool) -> None: self._attr_is_volume_muted = mute self.schedule_update_ha_state() - def volume_up(self) -> None: - """Increase volume.""" - assert self.volume_level is not None - self._attr_volume_level = min(1.0, self.volume_level + 0.1) - self.schedule_update_ha_state() - - def volume_down(self) -> None: - """Decrease volume.""" - assert self.volume_level is not None - self._attr_volume_level = max(0.0, self.volume_level - 0.1) - self.schedule_update_ha_state() - def set_volume_level(self, volume: float) -> None: """Set the volume level, range 0..1.""" self._attr_volume_level = volume diff --git a/homeassistant/components/demo/vacuum.py b/homeassistant/components/demo/vacuum.py index ba00bcaedb9db3..28bfea66be2b7d 100644 --- a/homeassistant/components/demo/vacuum.py +++ b/homeassistant/components/demo/vacuum.py @@ -7,6 +7,7 @@ from homeassistant.components.vacuum import ( ATTR_CLEANED_AREA, + Segment, StateVacuumEntity, VacuumActivity, VacuumEntityFeature, @@ -14,8 +15,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import event +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import DOMAIN + SUPPORT_MINIMAL_SERVICES = VacuumEntityFeature.TURN_ON | VacuumEntityFeature.TURN_OFF SUPPORT_BASIC_SERVICES = ( @@ -45,9 +49,17 @@ | VacuumEntityFeature.LOCATE | VacuumEntityFeature.MAP | VacuumEntityFeature.CLEAN_SPOT + | VacuumEntityFeature.CLEAN_AREA ) FAN_SPEEDS = ["min", "medium", "high", "max"] +DEMO_SEGMENTS = [ + Segment(id="living_room", name="Living room"), + Segment(id="kitchen", name="Kitchen"), + Segment(id="bedroom_1", name="Master bedroom", group="Bedrooms"), + Segment(id="bedroom_2", name="Guest bedroom", group="Bedrooms"), + Segment(id="bathroom", name="Bathroom"), +] DEMO_VACUUM_COMPLETE = "Demo vacuum 0 ground floor" DEMO_VACUUM_MOST = "Demo vacuum 1 first floor" DEMO_VACUUM_BASIC = "Demo vacuum 2 second floor" @@ -63,11 +75,11 @@ async def async_setup_entry( """Set up the Demo config entry.""" async_add_entities( [ - StateDemoVacuum(DEMO_VACUUM_COMPLETE, SUPPORT_ALL_SERVICES), - StateDemoVacuum(DEMO_VACUUM_MOST, SUPPORT_MOST_SERVICES), - StateDemoVacuum(DEMO_VACUUM_BASIC, SUPPORT_BASIC_SERVICES), - StateDemoVacuum(DEMO_VACUUM_MINIMAL, SUPPORT_MINIMAL_SERVICES), - StateDemoVacuum(DEMO_VACUUM_NONE, VacuumEntityFeature(0)), + StateDemoVacuum("vacuum_1", DEMO_VACUUM_COMPLETE, SUPPORT_ALL_SERVICES), + StateDemoVacuum("vacuum_2", DEMO_VACUUM_MOST, SUPPORT_MOST_SERVICES), + StateDemoVacuum("vacuum_3", DEMO_VACUUM_BASIC, SUPPORT_BASIC_SERVICES), + StateDemoVacuum("vacuum_4", DEMO_VACUUM_MINIMAL, SUPPORT_MINIMAL_SERVICES), + StateDemoVacuum("vacuum_5", DEMO_VACUUM_NONE, VacuumEntityFeature(0)), ] ) @@ -75,13 +87,21 @@ async def async_setup_entry( class StateDemoVacuum(StateVacuumEntity): """Representation of a demo vacuum supporting states.""" + _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False _attr_translation_key = "model_s" - def __init__(self, name: str, supported_features: VacuumEntityFeature) -> None: + def __init__( + self, unique_id: str, name: str, supported_features: VacuumEntityFeature + ) -> None: """Initialize the vacuum.""" - self._attr_name = name + self._attr_unique_id = unique_id self._attr_supported_features = supported_features + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=name, + ) self._attr_activity = VacuumActivity.DOCKED self._fan_speed = FAN_SPEEDS[1] self._cleaned_area: float = 0 @@ -163,6 +183,16 @@ async def async_send_command( self._attr_activity = VacuumActivity.IDLE self.async_write_ha_state() + async def async_get_segments(self) -> list[Segment]: + """Get the list of segments.""" + return DEMO_SEGMENTS + + async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None: + """Clean the specified segments.""" + self._attr_activity = VacuumActivity.CLEANING + self._cleaned_area += len(segment_ids) * 0.7 + self.async_write_ha_state() + def __set_state_to_dock(self, _: datetime) -> None: self._attr_activity = VacuumActivity.DOCKED self.schedule_update_ha_state() diff --git a/homeassistant/components/demo/valve.py b/homeassistant/components/demo/valve.py index eb415e8475c30b..4e90b10ada50fb 100644 --- a/homeassistant/components/demo/valve.py +++ b/homeassistant/components/demo/valve.py @@ -9,9 +9,12 @@ from homeassistant.components.valve import ValveEntity, ValveEntityFeature, ValveState from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_utc_time_change +from . import DOMAIN + OPEN_CLOSE_DELAY = 2 # Used to give a realistic open/close experience in frontend @@ -23,10 +26,10 @@ async def async_setup_entry( """Set up the Demo config entry.""" async_add_entities( [ - DemoValve("Front Garden", ValveState.OPEN), - DemoValve("Orchard", ValveState.CLOSED), - DemoValve("Back Garden", ValveState.CLOSED, position=70), - DemoValve("Trees", ValveState.CLOSED, position=30), + DemoValve("valve_1", "Front Garden", ValveState.OPEN), + DemoValve("valve_2", "Orchard", ValveState.CLOSED), + DemoValve("valve_3", "Back Garden", ValveState.CLOSED, position=70), + DemoValve("valve_4", "Trees", ValveState.CLOSED, position=30), ] ) @@ -34,17 +37,24 @@ async def async_setup_entry( class DemoValve(ValveEntity): """Representation of a Demo valve.""" + _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False def __init__( self, + unique_id: str, name: str, state: str, moveable: bool = True, position: int | None = None, ) -> None: """Initialize the valve.""" - self._attr_name = name + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=name, + ) if moveable: self._attr_supported_features = ( ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE diff --git a/homeassistant/components/demo/water_heater.py b/homeassistant/components/demo/water_heater.py index 9e12bb9e1d5c5d..6432ce22ddf066 100644 --- a/homeassistant/components/demo/water_heater.py +++ b/homeassistant/components/demo/water_heater.py @@ -30,9 +30,16 @@ async def async_setup_entry( async_add_entities( [ DemoWaterHeater( - "Demo Water Heater", 119, UnitOfTemperature.FAHRENHEIT, False, "eco", 1 + "demo_water_heater", + "Demo Water Heater", + 119, + UnitOfTemperature.FAHRENHEIT, + False, + "eco", + 1, ), DemoWaterHeater( + "demo_water_heater_celsius", "Demo Water Heater Celsius", 45, UnitOfTemperature.CELSIUS, @@ -52,6 +59,7 @@ class DemoWaterHeater(WaterHeaterEntity): def __init__( self, + unique_id: str, name: str, target_temperature: int, unit_of_measurement: str, @@ -60,6 +68,7 @@ def __init__( target_temperature_step: float, ) -> None: """Initialize the water_heater device.""" + self._attr_unique_id = unique_id self._attr_name = name if target_temperature is not None: self._attr_supported_features |= WaterHeaterEntityFeature.TARGET_TEMPERATURE diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index e0b4a19de647b2..8515b54295a1dc 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -10,13 +10,16 @@ from homeassistant.components.sensor import ( ATTR_STATE_CLASS, + DEVICE_CLASS_UNITS, PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, RestoreSensor, + SensorDeviceClass, SensorEntity, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_SOURCE, @@ -83,6 +86,17 @@ UnitOfTime.DAYS: 24 * 60 * 60, } +DERIVED_CLASS = { + SensorDeviceClass.ENERGY: SensorDeviceClass.POWER, + SensorDeviceClass.ENERGY_STORAGE: SensorDeviceClass.POWER, + SensorDeviceClass.DATA_SIZE: SensorDeviceClass.DATA_RATE, + SensorDeviceClass.DISTANCE: SensorDeviceClass.SPEED, + SensorDeviceClass.WATER: SensorDeviceClass.VOLUME_FLOW_RATE, + SensorDeviceClass.GAS: SensorDeviceClass.VOLUME_FLOW_RATE, + SensorDeviceClass.VOLUME: SensorDeviceClass.VOLUME_FLOW_RATE, + SensorDeviceClass.VOLUME_STORAGE: SensorDeviceClass.VOLUME_FLOW_RATE, +} + DEFAULT_ROUND = 3 DEFAULT_TIME_WINDOW = 0 @@ -203,10 +217,11 @@ def __init__( self._attr_name = name if name is not None else f"{source_entity} derivative" self._attr_extra_state_attributes = {ATTR_SOURCE_ID: source_entity} - self._unit_template: str | None = None + self._string_unit_prefix: str | None = None + self._string_unit_time: str | None = None if unit_of_measurement is None: - final_unit_prefix = "" if unit_prefix is None else unit_prefix - self._unit_template = f"{final_unit_prefix}{{}}/{unit_time}" + self._string_unit_prefix = "" if unit_prefix is None else unit_prefix + self._string_unit_time = unit_time # we postpone the definition of unit_of_measurement to later self._attr_native_unit_of_measurement = None else: @@ -225,12 +240,40 @@ def __init__( ) def _derive_and_set_attributes_from_state(self, source_state: State | None) -> None: - if self._unit_template and source_state: + if not source_state: + return + + source_class_raw = source_state.attributes.get(ATTR_DEVICE_CLASS) + source_class: SensorDeviceClass | None = None + if isinstance(source_class_raw, str): + try: + source_class = SensorDeviceClass(source_class_raw) + except ValueError: + source_class = None + if self._string_unit_prefix is not None and self._string_unit_time is not None: original_unit = self._attr_native_unit_of_measurement source_unit = source_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - self._attr_native_unit_of_measurement = self._unit_template.format( - "" if source_unit is None else source_unit - ) + if ( + ( + source_class + in (SensorDeviceClass.ENERGY, SensorDeviceClass.ENERGY_STORAGE) + ) + and self._string_unit_time == UnitOfTime.HOURS + and source_unit + and source_unit.endswith("Wh") + ): + self._attr_native_unit_of_measurement = ( + f"{self._string_unit_prefix}{source_unit[:-1]}" + ) + + else: + unit_template = ( + f"{self._string_unit_prefix}{{}}/{self._string_unit_time}" + ) + self._attr_native_unit_of_measurement = unit_template.format( + "" if source_unit is None else source_unit + ) + if original_unit != self._attr_native_unit_of_measurement: _LOGGER.debug( "%s: Derivative sensor switched UoM from %s to %s, resetting state to 0", @@ -241,6 +284,16 @@ def _derive_and_set_attributes_from_state(self, source_state: State | None) -> N self._state_list = [] self._attr_native_value = round(Decimal(0), self._round_digits) + self._attr_device_class = None + if source_class: + derived_class = DERIVED_CLASS.get(source_class) + if ( + derived_class + and self._attr_native_unit_of_measurement + in DEVICE_CLASS_UNITS[derived_class] + ): + self._attr_device_class = derived_class + def _calc_derivative_from_state_list(self, current_time: datetime) -> Decimal: def calculate_weight(start: datetime, end: datetime, now: datetime) -> float: window_start = now - timedelta(seconds=self._time_window) @@ -309,6 +362,10 @@ async def _handle_restore(self) -> None: except InvalidOperation, TypeError: self._attr_native_value = None + last_state = await self.async_get_last_state() + if last_state: + self._attr_device_class = last_state.attributes.get(ATTR_DEVICE_CLASS) + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() diff --git a/homeassistant/components/devialet/strings.json b/homeassistant/components/devialet/strings.json index cc3f2d270f8ae1..3e157561cddc97 100644 --- a/homeassistant/components/devialet/strings.json +++ b/homeassistant/components/devialet/strings.json @@ -13,7 +13,7 @@ }, "user": { "data": { - "host": "Host" + "host": "[%key:common::config_flow::data::host%]" }, "description": "Please enter the host name or IP address of the Devialet device." } diff --git a/homeassistant/components/device_sun_light_trigger/__init__.py b/homeassistant/components/device_sun_light_trigger/__init__.py index ee427eb1ba654d..b97f3cf32cf939 100644 --- a/homeassistant/components/device_sun_light_trigger/__init__.py +++ b/homeassistant/components/device_sun_light_trigger/__init__.py @@ -7,17 +7,17 @@ import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN as DOMAIN_DEVICE_TRACKER, + DOMAIN as DEVICE_TRACKER_DOMAIN, is_on as device_tracker_is_on, ) from homeassistant.components.group import get_entity_ids as group_get_entity_ids from homeassistant.components.light import ( ATTR_PROFILE, ATTR_TRANSITION, - DOMAIN as DOMAIN_LIGHT, + DOMAIN as LIGHT_DOMAIN, is_on as light_is_on, ) -from homeassistant.components.person import DOMAIN as DOMAIN_PERSON +from homeassistant.components.person import DOMAIN as PERSON_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_START, @@ -97,13 +97,13 @@ async def activate_automation( # noqa: C901 logger = logging.getLogger(__name__) if device_group is None: - device_entity_ids = hass.states.async_entity_ids(DOMAIN_DEVICE_TRACKER) + device_entity_ids = hass.states.async_entity_ids(DEVICE_TRACKER_DOMAIN) else: device_entity_ids = group_get_entity_ids( - hass, device_group, DOMAIN_DEVICE_TRACKER + hass, device_group, DEVICE_TRACKER_DOMAIN ) device_entity_ids.extend( - group_get_entity_ids(hass, device_group, DOMAIN_PERSON) + group_get_entity_ids(hass, device_group, PERSON_DOMAIN) ) if not device_entity_ids: @@ -112,9 +112,9 @@ async def activate_automation( # noqa: C901 # Get the light IDs from the specified group if light_group is None: - light_ids = hass.states.async_entity_ids(DOMAIN_LIGHT) + light_ids = hass.states.async_entity_ids(LIGHT_DOMAIN) else: - light_ids = group_get_entity_ids(hass, light_group, DOMAIN_LIGHT) + light_ids = group_get_entity_ids(hass, light_group, LIGHT_DOMAIN) if not light_ids: logger.error("No lights found to turn on") @@ -147,7 +147,7 @@ async def async_turn_on_before_sunset(light_id): if not anyone_home() or light_is_on(hass, light_id): return await hass.services.async_call( - DOMAIN_LIGHT, + LIGHT_DOMAIN, SERVICE_TURN_ON, { ATTR_ENTITY_ID: light_id, @@ -222,7 +222,7 @@ def check_light_on_dev_state_change( logger.info("Home coming event for %s. Turning lights on", entity) hass.async_create_task( hass.services.async_call( - DOMAIN_LIGHT, + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: light_ids, ATTR_PROFILE: light_profile}, ) @@ -241,7 +241,7 @@ def check_light_on_dev_state_change( if now > start_point + index * LIGHT_TRANSITION_TIME: hass.async_create_task( hass.services.async_call( - DOMAIN_LIGHT, SERVICE_TURN_ON, {ATTR_ENTITY_ID: light_id} + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: light_id} ) ) @@ -273,7 +273,7 @@ def turn_off_lights_when_all_leave(entity, old_state, new_state): logger.info("Everyone has left but there are lights on. Turning them off") hass.async_create_task( hass.services.async_call( - DOMAIN_LIGHT, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: light_ids} + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: light_ids} ) ) diff --git a/homeassistant/components/device_tracker/device_trigger.py b/homeassistant/components/device_tracker/device_trigger.py index bcd2f0f23428aa..cb299236438aca 100644 --- a/homeassistant/components/device_tracker/device_trigger.py +++ b/homeassistant/components/device_tracker/device_trigger.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA -from homeassistant.components.zone import DOMAIN as DOMAIN_ZONE, trigger as zone +from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN, trigger as zone from homeassistant.const import ( CONF_DEVICE_ID, CONF_DOMAIN, @@ -31,7 +31,7 @@ { vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), - vol.Required(CONF_ZONE): cv.entity_domain(DOMAIN_ZONE), + vol.Required(CONF_ZONE): cv.entity_domain(ZONE_DOMAIN), } ) @@ -83,7 +83,7 @@ async def async_attach_trigger( event = zone.EVENT_LEAVE zone_config = { - CONF_PLATFORM: DOMAIN_ZONE, + CONF_PLATFORM: ZONE_DOMAIN, CONF_ENTITY_ID: config[CONF_ENTITY_ID], CONF_ZONE: config[CONF_ZONE], CONF_EVENT: event, @@ -100,7 +100,7 @@ async def async_get_trigger_capabilities( """List trigger capabilities.""" zones = { ent.entity_id: ent.name - for ent in sorted(hass.states.async_all(DOMAIN_ZONE), key=attrgetter("name")) + for ent in sorted(hass.states.async_all(ZONE_DOMAIN), key=attrgetter("name")) } return { "extra_fields": vol.Schema( diff --git a/homeassistant/components/diagnostics/__init__.py b/homeassistant/components/diagnostics/__init__.py index 0cb2eddb199c24..a19f3c888e5dac 100644 --- a/homeassistant/components/diagnostics/__init__.py +++ b/homeassistant/components/diagnostics/__init__.py @@ -38,9 +38,9 @@ from homeassistant.util.json import format_unserializable_data from .const import DOMAIN, REDACTED, DiagnosticsSubType, DiagnosticsType -from .util import async_redact_data +from .util import async_redact_data, entity_entry_as_dict -__all__ = ["REDACTED", "async_redact_data"] +__all__ = ["REDACTED", "async_redact_data", "entity_entry_as_dict"] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/diagnostics/util.py b/homeassistant/components/diagnostics/util.py index 0ca85c9a58437f..374719647e47f2 100644 --- a/homeassistant/components/diagnostics/util.py +++ b/homeassistant/components/diagnostics/util.py @@ -5,7 +5,10 @@ from collections.abc import Iterable, Mapping from typing import Any, cast, overload +import attr + from homeassistant.core import callback +from homeassistant.helpers.entity_registry import RegistryEntry from .const import REDACTED @@ -42,3 +45,16 @@ def async_redact_data[_T](data: _T, to_redact: Iterable[Any]) -> _T: redacted[key] = [async_redact_data(item, to_redact) for item in value] return cast(_T, redacted) + + +def _entity_entry_filter(a: attr.Attribute, _: Any) -> bool: + return a.name not in ("_cache", "compat_aliases", "compat_name") + + +@callback +def entity_entry_as_dict(entry: RegistryEntry) -> dict[str, Any]: + """Convert an entity registry entry to a dict for diagnostics. + + This excludes internal fields that should not be exposed in diagnostics. + """ + return attr.asdict(entry, filter=_entity_entry_filter) diff --git a/homeassistant/components/dialogflow/strings.json b/homeassistant/components/dialogflow/strings.json index b357bf7cfe2bcd..48939ba9913cdd 100644 --- a/homeassistant/components/dialogflow/strings.json +++ b/homeassistant/components/dialogflow/strings.json @@ -2,6 +2,7 @@ "config": { "abort": { "cloud_not_connected": "[%key:common::config_flow::abort::cloud_not_connected%]", + "reconfigure_successful": "**Reconfiguration was successful**\n\nGo to the [webhook service of Dialogflow]({dialogflow_url}) and update the webhook with following settings:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) for further details.", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]" }, @@ -9,6 +10,10 @@ "default": "To send events to Home Assistant, you will need to set up the [webhook service of Dialogflow]({dialogflow_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) for further details." }, "step": { + "reconfigure": { + "description": "Are you sure you want to reconfigure Dialogflow?", + "title": "Reconfigure Dialogflow webhook" + }, "user": { "description": "Are you sure you want to set up Dialogflow?", "title": "Set up the Dialogflow webhook" diff --git a/homeassistant/components/directv/media_player.py b/homeassistant/components/directv/media_player.py index 91934a2da3a912..6f57375e8781bf 100644 --- a/homeassistant/components/directv/media_player.py +++ b/homeassistant/components/directv/media_player.py @@ -117,7 +117,7 @@ async def async_update(self) -> None: self._attr_assumed_state = self._is_recorded @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return device specific state attributes.""" if self._is_standby: return {} diff --git a/homeassistant/components/discogs/sensor.py b/homeassistant/components/discogs/sensor.py index 3c64b9020c33d4..cce4b5651db88c 100644 --- a/homeassistant/components/discogs/sensor.py +++ b/homeassistant/components/discogs/sensor.py @@ -5,6 +5,7 @@ from datetime import timedelta import logging import random +from typing import Any import discogs_client import voluptuous as vol @@ -118,7 +119,7 @@ def __init__( self._attr_name = f"{name} {description.name}" @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the device state attributes of the sensor.""" if self._attr_native_value is None or self._attrs is None: return None diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index f4bfc615608220..6505f63d363958 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pydoods"], "quality_scale": "legacy", - "requirements": ["pydoods==1.0.2", "Pillow==12.0.0"] + "requirements": ["pydoods==1.0.2", "Pillow==12.1.1"] } diff --git a/homeassistant/components/door/__init__.py b/homeassistant/components/door/__init__.py new file mode 100644 index 00000000000000..cd19966ffdf7f1 --- /dev/null +++ b/homeassistant/components/door/__init__.py @@ -0,0 +1,15 @@ +"""Integration for door triggers.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +DOMAIN = "door" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the component.""" + return True diff --git a/homeassistant/components/door/condition.py b/homeassistant/components/door/condition.py new file mode 100644 index 00000000000000..9889c5f5531798 --- /dev/null +++ b/homeassistant/components/door/condition.py @@ -0,0 +1,29 @@ +"""Provides conditions for doors.""" + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + CoverDeviceClass, + make_cover_is_closed_condition, + make_cover_is_open_condition, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.condition import Condition + +DEVICE_CLASSES_DOOR: dict[str, str] = { + BINARY_SENSOR_DOMAIN: BinarySensorDeviceClass.DOOR, + COVER_DOMAIN: CoverDeviceClass.DOOR, +} + +CONDITIONS: dict[str, type[Condition]] = { + "is_closed": make_cover_is_closed_condition(device_classes=DEVICE_CLASSES_DOOR), + "is_open": make_cover_is_open_condition(device_classes=DEVICE_CLASSES_DOOR), +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the conditions for doors.""" + return CONDITIONS diff --git a/homeassistant/components/door/conditions.yaml b/homeassistant/components/door/conditions.yaml new file mode 100644 index 00000000000000..ed1c3d79ec5da4 --- /dev/null +++ b/homeassistant/components/door/conditions.yaml @@ -0,0 +1,28 @@ +.condition_common_fields: &condition_common_fields + behavior: + required: true + default: any + selector: + select: + translation_key: condition_behavior + options: + - all + - any + +is_closed: + fields: *condition_common_fields + target: + entity: + - domain: binary_sensor + device_class: door + - domain: cover + device_class: door + +is_open: + fields: *condition_common_fields + target: + entity: + - domain: binary_sensor + device_class: door + - domain: cover + device_class: door diff --git a/homeassistant/components/door/icons.json b/homeassistant/components/door/icons.json new file mode 100644 index 00000000000000..89ceb8cdcd8ae3 --- /dev/null +++ b/homeassistant/components/door/icons.json @@ -0,0 +1,18 @@ +{ + "conditions": { + "is_closed": { + "condition": "mdi:door-closed" + }, + "is_open": { + "condition": "mdi:door-open" + } + }, + "triggers": { + "closed": { + "trigger": "mdi:door-closed" + }, + "opened": { + "trigger": "mdi:door-open" + } + } +} diff --git a/homeassistant/components/door/manifest.json b/homeassistant/components/door/manifest.json new file mode 100644 index 00000000000000..917ddaa5098e3a --- /dev/null +++ b/homeassistant/components/door/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "door", + "name": "Door", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/door", + "integration_type": "system", + "quality_scale": "internal" +} diff --git a/homeassistant/components/door/strings.json b/homeassistant/components/door/strings.json new file mode 100644 index 00000000000000..8cad12e029901e --- /dev/null +++ b/homeassistant/components/door/strings.json @@ -0,0 +1,68 @@ +{ + "common": { + "condition_behavior_description": "How the state should match on the targeted doors.", + "condition_behavior_name": "Behavior", + "trigger_behavior_description": "The behavior of the targeted doors to trigger on.", + "trigger_behavior_name": "Behavior" + }, + "conditions": { + "is_closed": { + "description": "Tests if one or more doors are closed.", + "fields": { + "behavior": { + "description": "[%key:component::door::common::condition_behavior_description%]", + "name": "[%key:component::door::common::condition_behavior_name%]" + } + }, + "name": "Door is closed" + }, + "is_open": { + "description": "Tests if one or more doors are open.", + "fields": { + "behavior": { + "description": "[%key:component::door::common::condition_behavior_description%]", + "name": "[%key:component::door::common::condition_behavior_name%]" + } + }, + "name": "Door is open" + } + }, + "selector": { + "condition_behavior": { + "options": { + "all": "All", + "any": "Any" + } + }, + "trigger_behavior": { + "options": { + "any": "Any", + "first": "First", + "last": "Last" + } + } + }, + "title": "Door", + "triggers": { + "closed": { + "description": "Triggers after one or more doors close.", + "fields": { + "behavior": { + "description": "[%key:component::door::common::trigger_behavior_description%]", + "name": "[%key:component::door::common::trigger_behavior_name%]" + } + }, + "name": "Door closed" + }, + "opened": { + "description": "Triggers after one or more doors open.", + "fields": { + "behavior": { + "description": "[%key:component::door::common::trigger_behavior_description%]", + "name": "[%key:component::door::common::trigger_behavior_name%]" + } + }, + "name": "Door opened" + } + } +} diff --git a/homeassistant/components/door/trigger.py b/homeassistant/components/door/trigger.py new file mode 100644 index 00000000000000..42c2e51ead8e69 --- /dev/null +++ b/homeassistant/components/door/trigger.py @@ -0,0 +1,30 @@ +"""Provides triggers for doors.""" + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + CoverDeviceClass, + make_cover_closed_trigger, + make_cover_opened_trigger, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.trigger import Trigger + +DEVICE_CLASSES_DOOR: dict[str, str] = { + BINARY_SENSOR_DOMAIN: BinarySensorDeviceClass.DOOR, + COVER_DOMAIN: CoverDeviceClass.DOOR, +} + + +TRIGGERS: dict[str, type[Trigger]] = { + "opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_DOOR), + "closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_DOOR), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for doors.""" + return TRIGGERS diff --git a/homeassistant/components/door/triggers.yaml b/homeassistant/components/door/triggers.yaml new file mode 100644 index 00000000000000..770a79f22215ad --- /dev/null +++ b/homeassistant/components/door/triggers.yaml @@ -0,0 +1,29 @@ +.trigger_common_fields: &trigger_common_fields + behavior: + required: true + default: any + selector: + select: + translation_key: trigger_behavior + options: + - first + - last + - any + +closed: + fields: *trigger_common_fields + target: + entity: + - domain: binary_sensor + device_class: door + - domain: cover + device_class: door + +opened: + fields: *trigger_common_fields + target: + entity: + - domain: binary_sensor + device_class: door + - domain: cover + device_class: door diff --git a/homeassistant/components/dovado/sensor.py b/homeassistant/components/dovado/sensor.py index 0129b990435239..06a2e935d79b67 100644 --- a/homeassistant/components/dovado/sensor.py +++ b/homeassistant/components/dovado/sensor.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from datetime import timedelta import re +from typing import Any import voluptuous as vol @@ -138,6 +139,6 @@ def update(self) -> None: self._attr_native_value = self._compute_state() @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return {k: v for k, v in self._data.state.items() if k not in ["date", "time"]} diff --git a/homeassistant/components/downloader/const.py b/homeassistant/components/downloader/const.py index 14160e4cd5dc0d..69c606a1c0942e 100644 --- a/homeassistant/components/downloader/const.py +++ b/homeassistant/components/downloader/const.py @@ -11,8 +11,7 @@ ATTR_SUBDIR = "subdir" ATTR_URL = "url" ATTR_OVERWRITE = "overwrite" - -CONF_DOWNLOAD_DIR = "download_dir" +ATTR_HEADERS = "headers" DOWNLOAD_FAILED_EVENT = "download_failed" DOWNLOAD_COMPLETED_EVENT = "download_completed" diff --git a/homeassistant/components/downloader/services.py b/homeassistant/components/downloader/services.py index 0ccaee232d73c0..74b503bebda6e8 100644 --- a/homeassistant/components/downloader/services.py +++ b/homeassistant/components/downloader/services.py @@ -19,6 +19,7 @@ from .const import ( _LOGGER, ATTR_FILENAME, + ATTR_HEADERS, ATTR_OVERWRITE, ATTR_SUBDIR, ATTR_URL, @@ -39,6 +40,7 @@ def download_file(service: ServiceCall) -> None: subdir: str | None = service.data.get(ATTR_SUBDIR) target_filename: str | None = service.data.get(ATTR_FILENAME) overwrite: bool = service.data[ATTR_OVERWRITE] + headers: dict[str, str] = service.data[ATTR_HEADERS] if subdir: # Check the path @@ -62,7 +64,7 @@ def do_download() -> None: final_path = None filename = target_filename try: - req = requests.get(url, stream=True, timeout=10) + req = requests.get(url, stream=True, headers=headers, timeout=10) if req.status_code != HTTPStatus.OK: _LOGGER.warning( @@ -162,6 +164,9 @@ def async_setup_services(hass: HomeAssistant) -> None: vol.Optional(ATTR_SUBDIR): cv.string, vol.Required(ATTR_URL): cv.url, vol.Optional(ATTR_OVERWRITE, default=False): cv.boolean, + vol.Optional(ATTR_HEADERS, default=dict): vol.Schema( + {cv.string: cv.string} + ), } ), ) diff --git a/homeassistant/components/downloader/services.yaml b/homeassistant/components/downloader/services.yaml index 54d06db56273f2..24f9f56ec11290 100644 --- a/homeassistant/components/downloader/services.yaml +++ b/homeassistant/components/downloader/services.yaml @@ -17,3 +17,9 @@ download_file: default: false selector: boolean: + headers: + default: {} + example: + Accept: application/json + selector: + object: diff --git a/homeassistant/components/downloader/strings.json b/homeassistant/components/downloader/strings.json index 2c1e0352c4e642..e18654212a8ded 100644 --- a/homeassistant/components/downloader/strings.json +++ b/homeassistant/components/downloader/strings.json @@ -28,6 +28,10 @@ "description": "Custom name for the downloaded file.", "name": "Filename" }, + "headers": { + "description": "Additional custom HTTP headers.", + "name": "Headers" + }, "overwrite": { "description": "Overwrite file if it exists.", "name": "Overwrite" 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/manifest.json b/homeassistant/components/dsmr/manifest.json index f9e78ac616f863..32366c5578400c 100644 --- a/homeassistant/components/dsmr/manifest.json +++ b/homeassistant/components/dsmr/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["dsmr_parser"], - "requirements": ["dsmr-parser==1.4.3"] + "requirements": ["dsmr-parser==1.5.0"] } diff --git a/homeassistant/components/duckdns/issue.py b/homeassistant/components/duckdns/issue.py index f2124f97fa051d..34a23fdbc639b0 100644 --- a/homeassistant/components/duckdns/issue.py +++ b/homeassistant/components/duckdns/issue.py @@ -38,3 +38,18 @@ def deprecate_yaml_issue(hass: HomeAssistant, *, import_success: bool) -> None: "url": "/config/integrations/dashboard/add?domain=duckdns" }, ) + + +def action_called_without_config_entry(hass: HomeAssistant) -> None: + """Deprecate the use of action without config entry.""" + + async_create_issue( + hass, + DOMAIN, + "deprecated_call_without_config_entry", + breaks_in_ha_version="2026.9.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_call_without_config_entry", + ) diff --git a/homeassistant/components/duckdns/services.py b/homeassistant/components/duckdns/services.py index ff2368a146729b..b6a0e5174bf631 100644 --- a/homeassistant/components/duckdns/services.py +++ b/homeassistant/components/duckdns/services.py @@ -15,6 +15,7 @@ from .const import ATTR_CONFIG_ENTRY, ATTR_TXT, DOMAIN, SERVICE_SET_TXT from .coordinator import DuckDnsConfigEntry from .helpers import update_duckdns +from .issue import action_called_without_config_entry SERVICE_TXT_SCHEMA = vol.Schema( { @@ -42,6 +43,7 @@ def get_config_entry( """Return config entry or raise if not found or not loaded.""" if entry_id is None: + action_called_without_config_entry(hass) if len(entries := hass.config_entries.async_entries(DOMAIN)) != 1: raise ServiceValidationError( translation_domain=DOMAIN, diff --git a/homeassistant/components/duckdns/strings.json b/homeassistant/components/duckdns/strings.json index 64625c9ac8657b..87262c913e32c4 100644 --- a/homeassistant/components/duckdns/strings.json +++ b/homeassistant/components/duckdns/strings.json @@ -16,7 +16,7 @@ "data_description": { "access_token": "[%key:component::duckdns::config::step::user::data_description::access_token%]" }, - "title": "Re-configure {name}" + "title": "Reconfigure {name}" }, "user": { "data": { @@ -46,6 +46,10 @@ } }, "issues": { + "deprecated_call_without_config_entry": { + "description": "Calling the `duckdns.set_txt` action without specifying a config entry is deprecated.\n\nThe `config_entry_id` field will be required in a future release.\n\nPlease update your automations and scripts to include the `config_entry_id` parameter.", + "title": "Detected deprecated use of action without config entry" + }, "deprecated_yaml_import_issue_error": { "description": "Configuring Duck DNS using YAML is being removed but there was an error when trying to import the YAML configuration.\n\nEnsure the YAML configuration is correct and restart Home Assistant to try again or remove the Duck DNS YAML configuration from your `configuration.yaml` file and continue to [set up the integration]({url}) manually.", "title": "The Duck DNS YAML configuration import failed" diff --git a/homeassistant/components/duke_energy/__init__.py b/homeassistant/components/duke_energy/__init__.py deleted file mode 100644 index bfa89d81c69e87..00000000000000 --- a/homeassistant/components/duke_energy/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -"""The Duke Energy integration.""" - -from __future__ import annotations - -from homeassistant.core import HomeAssistant - -from .coordinator import DukeEnergyConfigEntry, DukeEnergyCoordinator - - -async def async_setup_entry(hass: HomeAssistant, entry: DukeEnergyConfigEntry) -> bool: - """Set up Duke Energy from a config entry.""" - - coordinator = DukeEnergyCoordinator(hass, entry) - await coordinator.async_config_entry_first_refresh() - entry.runtime_data = coordinator - - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: DukeEnergyConfigEntry) -> bool: - """Unload a config entry.""" - return True diff --git a/homeassistant/components/duke_energy/config_flow.py b/homeassistant/components/duke_energy/config_flow.py deleted file mode 100644 index 78865e69086227..00000000000000 --- a/homeassistant/components/duke_energy/config_flow.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Config flow for Duke Energy integration.""" - -from __future__ import annotations - -import logging -from typing import Any - -from aiodukeenergy import DukeEnergy -from aiohttp import ClientError, ClientResponseError -import voluptuous as vol - -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers.aiohttp_client import async_get_clientsession - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -STEP_USER_DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - } -) - - -class DukeEnergyConfigFlow(ConfigFlow, domain=DOMAIN): - """Handle a config flow for Duke Energy.""" - - 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: - session = async_get_clientsession(self.hass) - api = DukeEnergy( - user_input[CONF_USERNAME], user_input[CONF_PASSWORD], session - ) - try: - auth = await api.authenticate() - except ClientResponseError as e: - errors["base"] = "invalid_auth" if e.status == 404 else "cannot_connect" - except ClientError, TimeoutError: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - username = auth["internalUserID"].lower() - await self.async_set_unique_id(username) - self._abort_if_unique_id_configured() - email = auth["loginEmailAddress"].lower() - data = { - CONF_EMAIL: email, - CONF_USERNAME: username, - CONF_PASSWORD: user_input[CONF_PASSWORD], - } - self._async_abort_entries_match(data) - return self.async_create_entry(title=email, data=data) - - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors - ) diff --git a/homeassistant/components/duke_energy/const.py b/homeassistant/components/duke_energy/const.py deleted file mode 100644 index 98c973fa2fc162..00000000000000 --- a/homeassistant/components/duke_energy/const.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Constants for the Duke Energy integration.""" - -DOMAIN = "duke_energy" diff --git a/homeassistant/components/duke_energy/coordinator.py b/homeassistant/components/duke_energy/coordinator.py deleted file mode 100644 index d1a78e83ace1e8..00000000000000 --- a/homeassistant/components/duke_energy/coordinator.py +++ /dev/null @@ -1,222 +0,0 @@ -"""Coordinator to handle Duke Energy connections.""" - -from datetime import datetime, timedelta -import logging -from typing import Any, cast - -from aiodukeenergy import DukeEnergy -from aiohttp import ClientError - -from homeassistant.components.recorder import get_instance -from homeassistant.components.recorder.models import ( - StatisticData, - StatisticMeanType, - StatisticMetaData, -) -from homeassistant.components.recorder.statistics import ( - async_add_external_statistics, - get_last_statistics, - statistics_during_period, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfEnergy, UnitOfVolume -from homeassistant.core import HomeAssistant, callback -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 homeassistant.util.unit_conversion import EnergyConverter - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -_SUPPORTED_METER_TYPES = ("ELECTRIC",) - -type DukeEnergyConfigEntry = ConfigEntry[DukeEnergyCoordinator] - - -class DukeEnergyCoordinator(DataUpdateCoordinator[None]): - """Handle inserting statistics.""" - - config_entry: DukeEnergyConfigEntry - - def __init__( - self, hass: HomeAssistant, config_entry: DukeEnergyConfigEntry - ) -> None: - """Initialize the data handler.""" - super().__init__( - hass, - _LOGGER, - config_entry=config_entry, - name="Duke Energy", - # Data is updated daily on Duke Energy. - # Refresh every 12h to be at most 12h behind. - update_interval=timedelta(hours=12), - ) - self.api = DukeEnergy( - config_entry.data[CONF_USERNAME], - config_entry.data[CONF_PASSWORD], - async_get_clientsession(hass), - ) - self._statistic_ids: set = set() - - @callback - def _dummy_listener() -> None: - pass - - # Force the coordinator to periodically update by registering at least one listener. - # Duke Energy does not provide forecast data, so all information is historical. - # This makes _async_update_data get periodically called so we can insert statistics. - self.async_add_listener(_dummy_listener) - - self.config_entry.async_on_unload(self._clear_statistics) - - def _clear_statistics(self) -> None: - """Clear statistics.""" - get_instance(self.hass).async_clear_statistics(list(self._statistic_ids)) - - async def _async_update_data(self) -> None: - """Insert Duke Energy statistics.""" - meters: dict[str, dict[str, Any]] = await self.api.get_meters() - for serial_number, meter in meters.items(): - if ( - not isinstance(meter["serviceType"], str) - or meter["serviceType"] not in _SUPPORTED_METER_TYPES - ): - _LOGGER.debug( - "Skipping unsupported meter type %s", meter["serviceType"] - ) - continue - - id_prefix = f"{meter['serviceType'].lower()}_{serial_number}" - consumption_statistic_id = f"{DOMAIN}:{id_prefix}_energy_consumption" - self._statistic_ids.add(consumption_statistic_id) - _LOGGER.debug( - "Updating Statistics for %s", - consumption_statistic_id, - ) - - last_stat = await get_instance(self.hass).async_add_executor_job( - get_last_statistics, self.hass, 1, consumption_statistic_id, True, set() - ) - if not last_stat: - _LOGGER.debug("Updating statistic for the first time") - usage = await self._async_get_energy_usage(meter) - consumption_sum = 0.0 - last_stats_time = None - else: - usage = await self._async_get_energy_usage( - meter, - last_stat[consumption_statistic_id][0]["start"], - ) - if not usage: - _LOGGER.debug("No recent usage data. Skipping update") - continue - stats = await get_instance(self.hass).async_add_executor_job( - statistics_during_period, - self.hass, - min(usage.keys()), - None, - {consumption_statistic_id}, - "hour", - None, - {"sum"}, - ) - consumption_sum = cast(float, stats[consumption_statistic_id][0]["sum"]) - last_stats_time = stats[consumption_statistic_id][0]["start"] - - consumption_statistics = [] - - for start, data in usage.items(): - if last_stats_time is not None and start.timestamp() <= last_stats_time: - continue - consumption_sum += data["energy"] - - consumption_statistics.append( - StatisticData( - start=start, state=data["energy"], sum=consumption_sum - ) - ) - - name_prefix = ( - f"Duke Energy {meter['serviceType'].capitalize()} {serial_number}" - ) - consumption_metadata = StatisticMetaData( - mean_type=StatisticMeanType.NONE, - has_sum=True, - name=f"{name_prefix} Consumption", - source=DOMAIN, - statistic_id=consumption_statistic_id, - unit_class=EnergyConverter.UNIT_CLASS, - unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR - if meter["serviceType"] == "ELECTRIC" - else UnitOfVolume.CENTUM_CUBIC_FEET, - ) - - _LOGGER.debug( - "Adding %s statistics for %s", - len(consumption_statistics), - consumption_statistic_id, - ) - async_add_external_statistics( - self.hass, consumption_metadata, consumption_statistics - ) - - async def _async_get_energy_usage( - self, meter: dict[str, Any], start_time: float | None = None - ) -> dict[datetime, dict[str, float | int]]: - """Get energy usage. - - If start_time is None, get usage since account activation (or as far back as possible), - otherwise since start_time - 30 days to allow corrections in data. - - Duke Energy provides hourly data all the way back to ~3 years. - """ - - # All of Duke Energy Service Areas are currently in America/New_York timezone - # May need to re-think this if that ever changes and determine timezone based - # on the service address somehow. - tz = await dt_util.async_get_time_zone("America/New_York") - lookback = timedelta(days=30) - one = timedelta(days=1) - if start_time is None: - # Max 3 years of data - start = dt_util.now(tz) - timedelta(days=3 * 365) - else: - start = datetime.fromtimestamp(start_time, tz=tz) - lookback - agreement_date = dt_util.parse_datetime(meter["agreementActiveDate"]) - if agreement_date is not None: - start = max(agreement_date.replace(tzinfo=tz), start) - - start = start.replace(hour=0, minute=0, second=0, microsecond=0) - end = dt_util.now(tz).replace(hour=0, minute=0, second=0, microsecond=0) - one - _LOGGER.debug("Data lookup range: %s - %s", start, end) - - start_step = max(end - lookback, start) - end_step = end - usage: dict[datetime, dict[str, float | int]] = {} - while True: - _LOGGER.debug("Getting hourly usage: %s - %s", start_step, end_step) - try: - # Get data - results = await self.api.get_energy_usage( - meter["serialNum"], "HOURLY", "DAY", start_step, end_step - ) - usage = {**results["data"], **usage} - - for missing in results["missing"]: - _LOGGER.debug("Missing data: %s", missing) - - # Set next range - end_step = start_step - one - start_step = max(start_step - lookback, start) - - # Make sure we don't go back too far - if end_step < start: - break - except TimeoutError, ClientError: - # ClientError is raised when there is no more data for the range - break - - _LOGGER.debug("Got %s meter usage reads", len(usage)) - return usage diff --git a/homeassistant/components/duke_energy/manifest.json b/homeassistant/components/duke_energy/manifest.json deleted file mode 100644 index cbce6db82a1db3..00000000000000 --- a/homeassistant/components/duke_energy/manifest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "domain": "duke_energy", - "name": "Duke Energy", - "codeowners": ["@hunterjm"], - "config_flow": true, - "dependencies": ["recorder"], - "documentation": "https://www.home-assistant.io/integrations/duke_energy", - "integration_type": "service", - "iot_class": "cloud_polling", - "requirements": ["aiodukeenergy==0.3.0"] -} diff --git a/homeassistant/components/duke_energy/strings.json b/homeassistant/components/duke_energy/strings.json deleted file mode 100644 index fed005957637a3..00000000000000 --- a/homeassistant/components/duke_energy/strings.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "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": { - "password": "[%key:common::config_flow::data::password%]", - "username": "[%key:common::config_flow::data::username%]" - } - } - } - } -} diff --git a/homeassistant/components/dunehd/media_player.py b/homeassistant/components/dunehd/media_player.py index b30932213856fc..3960d7b6d3a1ec 100644 --- a/homeassistant/components/dunehd/media_player.py +++ b/homeassistant/components/dunehd/media_player.py @@ -33,6 +33,8 @@ | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_MUTE ) diff --git a/homeassistant/components/dwd_weather_warnings/manifest.json b/homeassistant/components/dwd_weather_warnings/manifest.json index e74ea6fe8627b2..c43f4e1b5be74f 100644 --- a/homeassistant/components/dwd_weather_warnings/manifest.json +++ b/homeassistant/components/dwd_weather_warnings/manifest.json @@ -1,7 +1,7 @@ { "domain": "dwd_weather_warnings", "name": "Deutscher Wetterdienst (DWD) Weather Warnings", - "codeowners": ["@runningman84", "@stephan192", "@andarotajo"], + "codeowners": ["@runningman84", "@stephan192"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dwd_weather_warnings", "integration_type": "service", diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index 1c2817350a5075..6069fdc6a2fed5 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -11,6 +11,7 @@ from __future__ import annotations +from datetime import UTC, datetime from typing import Any from homeassistant.components.sensor import SensorEntity, SensorEntityDescription @@ -95,13 +96,25 @@ def __init__( entry_type=DeviceEntryType.SERVICE, ) + def _filter_expired_warnings( + self, warnings: list[dict[str, Any]] | None + ) -> list[dict[str, Any]]: + if warnings is None: + return [] + + now = datetime.now(UTC) + return [warning for warning in warnings if warning[API_ATTR_WARNING_END] > now] + @property def native_value(self) -> int | None: """Return the state of the sensor.""" if self.entity_description.key == CURRENT_WARNING_SENSOR: - return self.coordinator.api.current_warning_level + warnings = self.coordinator.api.current_warnings + else: + warnings = self.coordinator.api.expected_warnings - return self.coordinator.api.expected_warning_level + warnings = self._filter_expired_warnings(warnings) + return max((w.get(API_ATTR_WARNING_LEVEL, 0) for w in warnings), default=0) @property def extra_state_attributes(self) -> dict[str, Any]: @@ -117,6 +130,7 @@ def extra_state_attributes(self) -> dict[str, Any]: else: searched_warnings = self.coordinator.api.expected_warnings + searched_warnings = self._filter_expired_warnings(searched_warnings) data[ATTR_WARNING_COUNT] = len(searched_warnings) for i, warning in enumerate(searched_warnings, 1): diff --git a/homeassistant/components/eafm/__init__.py b/homeassistant/components/eafm/__init__.py index e2af2bae9f5e38..ff1d622139af28 100644 --- a/homeassistant/components/eafm/__init__.py +++ b/homeassistant/components/eafm/__init__.py @@ -2,14 +2,39 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from .const import DOMAIN from .coordinator import EafmConfigEntry, EafmCoordinator PLATFORMS = [Platform.SENSOR] +def _fix_device_registry_identifiers( + hass: HomeAssistant, entry: EafmConfigEntry +) -> None: + """Fix invalid identifiers in device registry. + + Added in 2026.4, can be removed in 2026.10 or later. + """ + device_registry = dr.async_get(hass) + for device_entry in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + old_identifier = (DOMAIN, "measure-id", entry.data["station"]) + 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.add((DOMAIN, entry.data["station"])) + device_registry.async_update_device( + device_entry.id, new_identifiers=new_identifiers + ) + + async def async_setup_entry(hass: HomeAssistant, entry: EafmConfigEntry) -> bool: """Set up flood monitoring sensors for this config entry.""" + _fix_device_registry_identifiers(hass, entry) coordinator = EafmCoordinator(hass, entry=entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/eafm/sensor.py b/homeassistant/components/eafm/sensor.py index 5d0af596521aa5..ce5aa35e6a26a3 100644 --- a/homeassistant/components/eafm/sensor.py +++ b/homeassistant/components/eafm/sensor.py @@ -94,11 +94,11 @@ def parameter_name(self): return self.coordinator.data["measures"][self.key]["parameterName"] @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device info.""" return DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, "measure-id", self.station_id)}, + identifiers={(DOMAIN, self.station_id)}, manufacturer="https://environment.data.gov.uk/", model=self.parameter_name, name=f"{self.station_name} {self.parameter_name} {self.qualifier}", diff --git a/homeassistant/components/ecobee/__init__.py b/homeassistant/components/ecobee/__init__.py index c34211e9ff0baa..080d269baa49a5 100644 --- a/homeassistant/components/ecobee/__init__.py +++ b/homeassistant/components/ecobee/__init__.py @@ -2,10 +2,17 @@ from datetime import timedelta -from pyecobee import ECOBEE_API_KEY, ECOBEE_REFRESH_TOKEN, Ecobee, ExpiredTokenError +from pyecobee import ( + ECOBEE_API_KEY, + ECOBEE_PASSWORD, + ECOBEE_REFRESH_TOKEN, + ECOBEE_USERNAME, + Ecobee, + ExpiredTokenError, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.util import Throttle @@ -18,10 +25,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: EcobeeConfigEntry) -> bool: """Set up ecobee via a config entry.""" - api_key = entry.data[CONF_API_KEY] + api_key = entry.data.get(CONF_API_KEY) + username = entry.data.get(CONF_USERNAME) + password = entry.data.get(CONF_PASSWORD) refresh_token = entry.data[CONF_REFRESH_TOKEN] - runtime_data = EcobeeData(hass, entry, api_key=api_key, refresh_token=refresh_token) + runtime_data = EcobeeData( + hass, + entry, + api_key=api_key, + username=username, + password=password, + refresh_token=refresh_token, + ) if not await runtime_data.refresh(): return False @@ -46,14 +62,32 @@ class EcobeeData: """ def __init__( - self, hass: HomeAssistant, entry: ConfigEntry, api_key: str, refresh_token: str + self, + hass: HomeAssistant, + entry: ConfigEntry, + api_key: str | None = None, + username: str | None = None, + password: str | None = None, + refresh_token: str | None = None, ) -> None: """Initialize the Ecobee data object.""" self._hass = hass self.entry = entry - self.ecobee = Ecobee( - config={ECOBEE_API_KEY: api_key, ECOBEE_REFRESH_TOKEN: refresh_token} - ) + + if api_key: + self.ecobee = Ecobee( + config={ECOBEE_API_KEY: api_key, ECOBEE_REFRESH_TOKEN: refresh_token} + ) + elif username and password: + self.ecobee = Ecobee( + config={ + ECOBEE_USERNAME: username, + ECOBEE_PASSWORD: password, + ECOBEE_REFRESH_TOKEN: refresh_token, + } + ) + else: + raise ValueError("No ecobee credentials provided") @Throttle(MIN_TIME_BETWEEN_UPDATES) async def update(self): @@ -69,12 +103,23 @@ async def refresh(self) -> bool: """Refresh ecobee tokens and update config entry.""" _LOGGER.debug("Refreshing ecobee tokens and updating config entry") if await self._hass.async_add_executor_job(self.ecobee.refresh_tokens): - self._hass.config_entries.async_update_entry( - self.entry, - data={ + data = {} + if self.ecobee.config.get(ECOBEE_API_KEY): + data = { CONF_API_KEY: self.ecobee.config[ECOBEE_API_KEY], CONF_REFRESH_TOKEN: self.ecobee.config[ECOBEE_REFRESH_TOKEN], - }, + } + elif self.ecobee.config.get(ECOBEE_USERNAME) and self.ecobee.config.get( + ECOBEE_PASSWORD + ): + data = { + CONF_USERNAME: self.ecobee.config[ECOBEE_USERNAME], + CONF_PASSWORD: self.ecobee.config[ECOBEE_PASSWORD], + CONF_REFRESH_TOKEN: self.ecobee.config[ECOBEE_REFRESH_TOKEN], + } + self._hass.config_entries.async_update_entry( + self.entry, + data=data, ) return True _LOGGER.error("Error refreshing ecobee tokens") diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index fdfd8059d468d0..62bb3886107278 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -490,14 +490,14 @@ def target_temperature(self) -> float | None: return None @property - def fan(self): + def fan(self) -> str: """Return the current fan status.""" if "fan" in self.thermostat["equipmentStatus"]: return STATE_ON return STATE_OFF @property - def fan_mode(self): + def fan_mode(self) -> str: """Return the fan setting.""" return self.thermostat["runtime"]["desiredFanMode"] @@ -535,7 +535,7 @@ def preset_mode(self) -> str | None: return None @property - def hvac_mode(self): + def hvac_mode(self) -> HVACMode: """Return current operation.""" return ECOBEE_HVAC_TO_HASS[self.settings["hvacMode"]] @@ -548,7 +548,7 @@ def current_humidity(self) -> int | None: return None @property - def hvac_action(self): + def hvac_action(self) -> HVACAction: """Return current HVAC action. Ecobee returns a CSV string with different equipment that is active. diff --git a/homeassistant/components/ecobee/config_flow.py b/homeassistant/components/ecobee/config_flow.py index 9c9d85223614de..2340cb56140df1 100644 --- a/homeassistant/components/ecobee/config_flow.py +++ b/homeassistant/components/ecobee/config_flow.py @@ -2,15 +2,21 @@ from typing import Any -from pyecobee import ECOBEE_API_KEY, Ecobee +from pyecobee import ECOBEE_API_KEY, ECOBEE_PASSWORD, ECOBEE_USERNAME, Ecobee import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME from .const import CONF_REFRESH_TOKEN, DOMAIN -_USER_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str}) +_USER_SCHEMA = vol.Schema( + { + vol.Optional(CONF_API_KEY): str, + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + } +) class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN): @@ -27,13 +33,34 @@ async def async_step_user( errors = {} if user_input is not None: - # Use the user-supplied API key to attempt to obtain a PIN from ecobee. - self._ecobee = Ecobee(config={ECOBEE_API_KEY: user_input[CONF_API_KEY]}) - - if await self.hass.async_add_executor_job(self._ecobee.request_pin): - # We have a PIN; move to the next step of the flow. - return await self.async_step_authorize() - errors["base"] = "pin_request_failed" + api_key = user_input.get(CONF_API_KEY) + username = user_input.get(CONF_USERNAME) + password = user_input.get(CONF_PASSWORD) + + if api_key and not (username or password): + # Use the user-supplied API key to attempt to obtain a PIN from ecobee. + self._ecobee = Ecobee(config={ECOBEE_API_KEY: api_key}) + if await self.hass.async_add_executor_job(self._ecobee.request_pin): + # We have a PIN; move to the next step of the flow. + return await self.async_step_authorize() + errors["base"] = "pin_request_failed" + elif username and password and not api_key: + self._ecobee = Ecobee( + config={ + ECOBEE_USERNAME: username, + ECOBEE_PASSWORD: password, + } + ) + if await self.hass.async_add_executor_job(self._ecobee.refresh_tokens): + config = { + CONF_USERNAME: username, + CONF_PASSWORD: password, + CONF_REFRESH_TOKEN: self._ecobee.refresh_token, + } + return self.async_create_entry(title=DOMAIN, data=config) + errors["base"] = "login_failed" + else: + errors["base"] = "invalid_auth" return self.async_show_form( step_id="user", diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json index 67ca625c637b9c..62ab46aad9d9be 100644 --- a/homeassistant/components/ecobee/strings.json +++ b/homeassistant/components/ecobee/strings.json @@ -4,6 +4,8 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" }, "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "login_failed": "Error authenticating with ecobee; please verify your credentials are correct.", "pin_request_failed": "Error requesting PIN from ecobee; please verify API key is correct.", "token_request_failed": "Error requesting tokens from ecobee; please try again." }, diff --git a/homeassistant/components/ecobee/weather.py b/homeassistant/components/ecobee/weather.py index 2112842112a46c..8c918db3038fc4 100644 --- a/homeassistant/components/ecobee/weather.py +++ b/homeassistant/components/ecobee/weather.py @@ -28,7 +28,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from . import EcobeeConfigEntry +from . import EcobeeConfigEntry, EcobeeData from .const import ( DOMAIN, ECOBEE_MODEL_TO_NAME, @@ -64,7 +64,7 @@ class EcobeeWeather(WeatherEntity): _attr_name = None _attr_supported_features = WeatherEntityFeature.FORECAST_DAILY - def __init__(self, data, name, index): + def __init__(self, data: EcobeeData, name: str, index: int) -> None: """Initialize the Ecobee weather platform.""" self.data = data self._name = name @@ -99,7 +99,7 @@ def device_info(self) -> DeviceInfo: ) @property - def condition(self): + def condition(self) -> str | None: """Return the current condition.""" try: return ECOBEE_WEATHER_SYMBOL_TO_HASS[self.get_forecast(0, "weatherSymbol")] @@ -107,7 +107,7 @@ def condition(self): return None @property - def native_temperature(self): + def native_temperature(self) -> float | None: """Return the temperature.""" try: return float(self.get_forecast(0, "temperature")) / 10 @@ -115,7 +115,7 @@ def native_temperature(self): return None @property - def native_pressure(self): + def native_pressure(self) -> float | None: """Return the pressure.""" try: pressure = self.get_forecast(0, "pressure") @@ -124,7 +124,7 @@ def native_pressure(self): return None @property - def humidity(self): + def humidity(self) -> float | None: """Return the humidity.""" try: return int(self.get_forecast(0, "relativeHumidity")) @@ -132,7 +132,7 @@ def humidity(self): return None @property - def native_visibility(self): + def native_visibility(self) -> float | None: """Return the visibility.""" try: return int(self.get_forecast(0, "visibility")) @@ -140,7 +140,7 @@ def native_visibility(self): return None @property - def native_wind_speed(self): + def native_wind_speed(self) -> float | None: """Return the wind speed.""" try: return int(self.get_forecast(0, "windSpeed")) @@ -148,7 +148,7 @@ def native_wind_speed(self): return None @property - def wind_bearing(self): + def wind_bearing(self) -> float | None: """Return the wind direction.""" try: return int(self.get_forecast(0, "windBearing")) @@ -156,7 +156,7 @@ def wind_bearing(self): return None @property - def attribution(self): + def attribution(self) -> str | None: """Return the attribution.""" if not self.weather: return None @@ -167,7 +167,7 @@ def attribution(self): def _forecast(self) -> list[Forecast] | None: """Return the forecast array.""" - if "forecasts" not in self.weather: + if not self.weather or "forecasts" not in self.weather: return None forecasts: list[Forecast] = [] diff --git a/homeassistant/components/econet/__init__.py b/homeassistant/components/econet/__init__.py index 40bece93599580..e2f15ee75644d2 100644 --- a/homeassistant/components/econet/__init__.py +++ b/homeassistant/components/econet/__init__.py @@ -28,6 +28,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.WATER_HEATER, diff --git a/homeassistant/components/econet/binary_sensor.py b/homeassistant/components/econet/binary_sensor.py index 0d041dfca5aab4..b9bcd72dd28739 100644 --- a/homeassistant/components/econet/binary_sensor.py +++ b/homeassistant/components/econet/binary_sensor.py @@ -74,6 +74,6 @@ def __init__( ) @property - def is_on(self): + def is_on(self) -> bool: """Return true if the binary sensor is on.""" return getattr(self._econet, self.entity_description.key) diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index 81fc7ceb2980c0..37c930f94e1ac4 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -5,7 +5,7 @@ from pyeconet.equipment import EquipmentType from pyeconet.equipment.thermostat import ( Thermostat, - ThermostatFanMode, + ThermostatFanSpeed, ThermostatOperationMode, ) @@ -16,6 +16,7 @@ FAN_HIGH, FAN_LOW, FAN_MEDIUM, + FAN_TOP, ClimateEntity, ClimateEntityFeature, HVACMode, @@ -41,13 +42,16 @@ if key != ThermostatOperationMode.EMERGENCY_HEAT } -ECONET_FAN_STATE_TO_HA = { - ThermostatFanMode.AUTO: FAN_AUTO, - ThermostatFanMode.LOW: FAN_LOW, - ThermostatFanMode.MEDIUM: FAN_MEDIUM, - ThermostatFanMode.HIGH: FAN_HIGH, +ECONET_FAN_SPEED_TO_HA = { + ThermostatFanSpeed.AUTO: FAN_AUTO, + ThermostatFanSpeed.LOW: FAN_LOW, + ThermostatFanSpeed.MEDIUM: FAN_MEDIUM, + ThermostatFanSpeed.HIGH: FAN_HIGH, + ThermostatFanSpeed.MAX: FAN_TOP, +} +HA_FAN_STATE_TO_ECONET_FAN_SPEED = { + value: key for key, value in ECONET_FAN_SPEED_TO_HA.items() } -HA_FAN_STATE_TO_ECONET = {value: key for key, value in ECONET_FAN_STATE_TO_HA.items()} SUPPORT_FLAGS_THERMOSTAT = ( ClimateEntityFeature.TARGET_TEMPERATURE @@ -103,7 +107,7 @@ def current_temperature(self) -> int: return self._econet.set_point @property - def current_humidity(self) -> int: + def current_humidity(self) -> int | None: """Return the current humidity.""" return self._econet.humidity @@ -149,7 +153,7 @@ def set_temperature(self, **kwargs: Any) -> None: @property def hvac_mode(self) -> HVACMode: - """Return hvac operation ie. heat, cool, mode. + """Return hvac operation i.e. heat, cool, mode. Needs to be one of HVAC_MODE_*. """ @@ -174,35 +178,35 @@ def set_humidity(self, humidity: int) -> None: @property def fan_mode(self) -> str: """Return the current fan mode.""" - econet_fan_mode = self._econet.fan_mode + econet_fan_speed = self._econet.fan_speed # Remove this after we figure out how to handle med lo and med hi - if econet_fan_mode in [ThermostatFanMode.MEDHI, ThermostatFanMode.MEDLO]: - econet_fan_mode = ThermostatFanMode.MEDIUM + if econet_fan_speed in [ThermostatFanSpeed.MEDHI, ThermostatFanSpeed.MEDLO]: + econet_fan_speed = ThermostatFanSpeed.MEDIUM - _current_fan_mode = FAN_AUTO - if econet_fan_mode is not None: - _current_fan_mode = ECONET_FAN_STATE_TO_HA[econet_fan_mode] - return _current_fan_mode + _current_fan_speed = FAN_AUTO + if econet_fan_speed is not None: + _current_fan_speed = ECONET_FAN_SPEED_TO_HA[econet_fan_speed] + return _current_fan_speed @property def fan_modes(self) -> list[str]: """Return the fan modes.""" + # Remove the MEDLO MEDHI once we figure out how to handle it return [ - ECONET_FAN_STATE_TO_HA[mode] - for mode in self._econet.fan_modes - # Remove the MEDLO MEDHI once we figure out how to handle it + ECONET_FAN_SPEED_TO_HA[mode] + for mode in self._econet.fan_speeds if mode not in [ - ThermostatFanMode.UNKNOWN, - ThermostatFanMode.MEDLO, - ThermostatFanMode.MEDHI, + ThermostatFanSpeed.UNKNOWN, + ThermostatFanSpeed.MEDLO, + ThermostatFanSpeed.MEDHI, ] ] def set_fan_mode(self, fan_mode: str) -> None: """Set the fan mode.""" - self._econet.set_fan_mode(HA_FAN_STATE_TO_ECONET[fan_mode]) + self._econet.set_fan_speed(HA_FAN_STATE_TO_ECONET_FAN_SPEED[fan_mode]) @property def min_temp(self) -> float: diff --git a/homeassistant/components/econet/manifest.json b/homeassistant/components/econet/manifest.json index c19a6a6f414818..069bb8477d0241 100644 --- a/homeassistant/components/econet/manifest.json +++ b/homeassistant/components/econet/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["paho_mqtt", "pyeconet"], - "requirements": ["pyeconet==0.1.28"] + "requirements": ["pyeconet==0.2.2"] } diff --git a/homeassistant/components/econet/select.py b/homeassistant/components/econet/select.py new file mode 100644 index 00000000000000..35d5e55d679d7e --- /dev/null +++ b/homeassistant/components/econet/select.py @@ -0,0 +1,53 @@ +"""Support for Rheem EcoNet thermostats with variable fan speeds and fan modes.""" + +from __future__ import annotations + +from pyeconet.equipment import EquipmentType +from pyeconet.equipment.thermostat import Thermostat, ThermostatFanMode + +from homeassistant.components.select import SelectEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import EconetConfigEntry +from .entity import EcoNetEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EconetConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the econet thermostat select entity.""" + equipment = entry.runtime_data + async_add_entities( + EconetFanModeSelect(thermostat) + for thermostat in equipment[EquipmentType.THERMOSTAT] + if thermostat.supports_fan_mode + ) + + +class EconetFanModeSelect(EcoNetEntity[Thermostat], SelectEntity): + """Select entity.""" + + def __init__(self, thermostat: Thermostat) -> None: + """Initialize EcoNet platform.""" + super().__init__(thermostat) + self._attr_name = f"{thermostat.device_name} fan mode" + self._attr_unique_id = ( + f"{thermostat.device_id}_{thermostat.device_name}_fan_mode" + ) + + @property + def options(self) -> list[str]: + """Return available select options.""" + return [e.value for e in self._econet.fan_modes] + + @property + def current_option(self) -> str: + """Return current select option.""" + return self._econet.fan_mode.value + + def select_option(self, option: str) -> None: + """Set the selected option.""" + self._econet.set_fan_mode(ThermostatFanMode.by_string(option)) diff --git a/homeassistant/components/econet/switch.py b/homeassistant/components/econet/switch.py index ff7f017b49fec7..a19100baf9ce84 100644 --- a/homeassistant/components/econet/switch.py +++ b/homeassistant/components/econet/switch.py @@ -23,19 +23,20 @@ async def async_setup_entry( entry: EconetConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the ecobee thermostat switch entity.""" + """Set up the econet thermostat switch entity.""" equipment = entry.runtime_data async_add_entities( EcoNetSwitchAuxHeatOnly(thermostat) for thermostat in equipment[EquipmentType.THERMOSTAT] + if ThermostatOperationMode.EMERGENCY_HEAT in thermostat.modes ) class EcoNetSwitchAuxHeatOnly(EcoNetEntity[Thermostat], SwitchEntity): - """Representation of a aux_heat_only EcoNet switch.""" + """Representation of an aux_heat_only EcoNet switch.""" def __init__(self, thermostat: Thermostat) -> None: - """Initialize EcoNet ventilator platform.""" + """Initialize EcoNet platform.""" super().__init__(thermostat) self._attr_name = f"{thermostat.device_name} emergency heat" self._attr_unique_id = ( diff --git a/homeassistant/components/econet/water_heater.py b/homeassistant/components/econet/water_heater.py index f93ad7f8872e13..876d9270bc914f 100644 --- a/homeassistant/components/econet/water_heater.py +++ b/homeassistant/components/econet/water_heater.py @@ -136,12 +136,12 @@ def target_temperature(self) -> int: return self.water_heater.set_point @property - def min_temp(self): + def min_temp(self) -> float: """Return the minimum temperature.""" return self.water_heater.set_point_limits[0] @property - def max_temp(self): + def max_temp(self) -> float: """Return the maximum temperature.""" return self.water_heater.set_point_limits[1] diff --git a/homeassistant/components/ecovacs/__init__.py b/homeassistant/components/ecovacs/__init__.py index 2e11b96e7d487c..9e64dc63c9afda 100644 --- a/homeassistant/components/ecovacs/__init__.py +++ b/homeassistant/components/ecovacs/__init__.py @@ -38,12 +38,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: EcovacsConfigEntry) -> bool: """Set up this integration using UI.""" controller = EcovacsController(hass, entry.data) - await controller.initialize() - async def on_unload() -> None: - await controller.teardown() + entry.async_on_unload(controller.teardown) + + await controller.initialize() - entry.async_on_unload(on_unload) entry.runtime_data = controller async def _async_wait_connect(device: VacBot) -> None: diff --git a/homeassistant/components/ecovacs/controller.py b/homeassistant/components/ecovacs/controller.py index 69dd0f0813f0ad..127262f00bf425 100644 --- a/homeassistant/components/ecovacs/controller.py +++ b/homeassistant/components/ecovacs/controller.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections.abc import Mapping from functools import partial import logging @@ -80,11 +81,22 @@ async def initialize(self) -> None: try: devices = await self._api_client.get_devices() credentials = await self._authenticator.authenticate() - for device_info in devices.mqtt: - device = Device(device_info, self._authenticator) + + if devices.mqtt: mqtt = await self._get_mqtt_client() - await device.initialize(mqtt) - self._devices.append(device) + mqtt_devices = [ + Device(info, self._authenticator) for info in devices.mqtt + ] + async with asyncio.TaskGroup() as tg: + + async def _init(device: Device) -> None: + """Initialize MQTT device.""" + await device.initialize(mqtt) + self._devices.append(device) + + for device in mqtt_devices: + tg.create_task(_init(device)) + for device_config in devices.xmpp: bot = VacBot( credentials.user_id, diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 424be24f529fc0..abfa385e95bc20 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==17.1.0"] + "requirements": ["py-sucks==0.9.11", "deebot-client==18.0.0"] } diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py index 77d0093fb3b13b..19ddfa0562fe2d 100644 --- a/homeassistant/components/ecovacs/vacuum.py +++ b/homeassistant/components/ecovacs/vacuum.py @@ -8,17 +8,24 @@ from deebot_client.capabilities import Capabilities, DeviceType from deebot_client.device import Device -from deebot_client.events import FanSpeedEvent, RoomsEvent, StateEvent -from deebot_client.models import CleanAction, CleanMode, Room, State +from deebot_client.events import ( + CachedMapInfoEvent, + FanSpeedEvent, + RoomsEvent, + StateEvent, +) +from deebot_client.events.map import Map +from deebot_client.models import CleanAction, CleanMode, State import sucks from homeassistant.components.vacuum import ( + Segment, StateVacuumEntity, StateVacuumEntityDescription, VacuumActivity, VacuumEntityFeature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import slugify @@ -29,6 +36,7 @@ from .util import get_name_key _LOGGER = logging.getLogger(__name__) +_SEGMENTS_SEPARATOR = "_" ATTR_ERROR = "error" @@ -218,7 +226,8 @@ def __init__(self, device: Device) -> None: """Initialize the vacuum.""" super().__init__(device, device.capabilities) - self._rooms: list[Room] = [] + self._room_event: RoomsEvent | None = None + self._maps: dict[str, Map] = {} if fan_speed := self._capability.fan_speed: self._attr_supported_features |= VacuumEntityFeature.FAN_SPEED @@ -226,14 +235,13 @@ def __init__(self, device: Device) -> None: get_name_key(level) for level in fan_speed.types ] + if self._capability.map and self._capability.clean.action.area: + self._attr_supported_features |= VacuumEntityFeature.CLEAN_AREA + async def async_added_to_hass(self) -> None: """Set up the event listeners now that hass is ready.""" await super().async_added_to_hass() - async def on_rooms(event: RoomsEvent) -> None: - self._rooms = event.rooms - self.async_write_ha_state() - async def on_status(event: StateEvent) -> None: self._attr_activity = _STATE_TO_VACUUM_STATE[event.state] self.async_write_ha_state() @@ -249,8 +257,20 @@ async def on_fan_speed(event: FanSpeedEvent) -> None: self._subscribe(self._capability.fan_speed.event, on_fan_speed) if map_caps := self._capability.map: + + async def on_rooms(event: RoomsEvent) -> None: + self._room_event = event + self._check_segments_changed() + self.async_write_ha_state() + self._subscribe(map_caps.rooms.event, on_rooms) + async def on_map_info(event: CachedMapInfoEvent) -> None: + self._maps = {map_obj.id: map_obj for map_obj in event.maps} + self._check_segments_changed() + + self._subscribe(map_caps.cached_info.event, on_map_info) + @property def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return entity specific state attributes. @@ -259,7 +279,10 @@ def extra_state_attributes(self) -> Mapping[str, Any] | None: is lowercase snake_case. """ rooms: dict[str, Any] = {} - for room in self._rooms: + if self._room_event is None: + return rooms + + for room in self._room_event.rooms: # convert room name to snake_case to meet the convention room_name = slugify(room.name) room_values = rooms.get(room_name) @@ -338,11 +361,11 @@ async def async_send_command( translation_placeholders={"name": name}, ) - if command in "spot_area": + if command == "spot_area": await self._device.execute_command( self._capability.clean.action.area( CleanMode.SPOT_AREA, - str(params["rooms"]), + params["rooms"], params.get("cleanings", 1), ) ) @@ -350,7 +373,7 @@ async def async_send_command( await self._device.execute_command( self._capability.clean.action.area( CleanMode.CUSTOM_AREA, - str(params["coordinates"]), + params["coordinates"], params.get("cleanings", 1), ) ) @@ -374,3 +397,116 @@ async def async_raw_get_positions( ) return await self._device.execute_command(position_commands[0]) + + @callback + def _check_segments_changed(self) -> None: + """Check if segments have changed and create repair issue.""" + last_seen = self.last_seen_segments + if last_seen is None: + return + + last_seen_ids = {seg.id for seg in last_seen} + current_ids = {seg.id for seg in self._get_segments()} + + if current_ids != last_seen_ids: + self.async_create_segments_issue() + + def _get_segments(self) -> list[Segment]: + """Get the segments that can be cleaned.""" + last_seen = self.last_seen_segments or [] + if self._room_event is None or not self._maps: + # If we don't have the necessary information to determine segments, return the last + # seen segments to avoid temporarily losing all segments until we get the necessary + # information, which could cause unnecessary issues to be created + return last_seen + + map_id = self._room_event.map_id + if (map_obj := self._maps.get(map_id)) is None: + _LOGGER.warning("Map ID %s not found in available maps", map_id) + return [] + + id_prefix = f"{map_id}{_SEGMENTS_SEPARATOR}" + other_map_ids = { + map_obj.id + for map_obj in self._maps.values() + if map_obj.id != self._room_event.map_id + } + # Include segments from the current map and any segments from other maps that were + # previously seen, as we want to continue showing segments from other maps for + # mapping purposes + segments = [ + seg for seg in last_seen if _split_composite_id(seg.id)[0] in other_map_ids + ] + segments.extend( + Segment( + id=f"{id_prefix}{room.id}", + name=room.name, + group=map_obj.name, + ) + for room in self._room_event.rooms + ) + return segments + + async def async_get_segments(self) -> list[Segment]: + """Get the segments that can be cleaned.""" + return self._get_segments() + + async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None: + """Perform an area clean. + + Only cleans segments from the currently selected map. + """ + if not self._maps: + _LOGGER.warning("No map information available, cannot clean segments") + return + + valid_room_ids: list[int | float] = [] + for composite_id in segment_ids: + map_id, segment_id = _split_composite_id(composite_id) + if (map_obj := self._maps.get(map_id)) is None: + _LOGGER.warning("Map ID %s not found in available maps", map_id) + continue + + if not map_obj.using: + room_name = next( + ( + segment.name + for segment in self.last_seen_segments or [] + if segment.id == composite_id + ), + "", + ) + _LOGGER.warning( + 'Map "%s" is not currently selected, skipping segment "%s" (%s)', + map_obj.name, + room_name, + segment_id, + ) + continue + + valid_room_ids.append(int(segment_id)) + + if not valid_room_ids: + _LOGGER.warning( + "No valid segments to clean after validation, skipping clean segments command" + ) + return + + if TYPE_CHECKING: + # Supported feature is only added if clean.action.area is not None + assert self._capability.clean.action.area is not None + + await self._device.execute_command( + self._capability.clean.action.area( + CleanMode.SPOT_AREA, + valid_room_ids, + 1, + ) + ) + + +@callback +def _split_composite_id(composite_id: str) -> tuple[str, str]: + """Split a composite ID into its components.""" + map_id, _, segment_id = composite_id.partition(_SEGMENTS_SEPARATOR) + return map_id, segment_id diff --git a/homeassistant/components/edimax/switch.py b/homeassistant/components/edimax/switch.py index 5482143fc372cc..ccf439059b18f4 100644 --- a/homeassistant/components/edimax/switch.py +++ b/homeassistant/components/edimax/switch.py @@ -53,25 +53,9 @@ class SmartPlugSwitch(SwitchEntity): def __init__(self, smartplug, name): """Initialize the switch.""" self.smartplug = smartplug - self._name = name - self._state = False + self._attr_name = name + self._attr_is_on = False self._info = None - self._mac = None - - @property - def unique_id(self): - """Return the device's MAC address.""" - return self._mac - - @property - def name(self): - """Return the name of the Smart Plug, if any.""" - return self._name - - @property - def is_on(self): - """Return true if switch is on.""" - return self._state def turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" @@ -85,6 +69,6 @@ def update(self) -> None: """Update edimax switch.""" if not self._info: self._info = self.smartplug.info - self._mac = self._info["mac"] + self._attr_unique_id = self._info["mac"] - self._state = self.smartplug.state == "ON" + self._attr_is_on = self.smartplug.state == "ON" diff --git a/homeassistant/components/egauge/sensor.py b/homeassistant/components/egauge/sensor.py index f5cd776ca35493..743bc34a429731 100644 --- a/homeassistant/components/egauge/sensor.py +++ b/homeassistant/components/egauge/sensor.py @@ -5,7 +5,7 @@ from collections.abc import Callable from dataclasses import dataclass -from egauge_async.json.models import RegisterType +from egauge_async.json.models import RegisterInfo, RegisterType from homeassistant.components.sensor import ( SensorDeviceClass, @@ -13,7 +13,12 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import UnitOfEnergy, UnitOfPower +from homeassistant.const import ( + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -27,6 +32,7 @@ class EgaugeSensorEntityDescription(SensorEntityDescription): native_value_fn: Callable[[EgaugeData, str], float] available_fn: Callable[[EgaugeData, str], bool] + supported_fn: Callable[[RegisterInfo], bool] SENSORS: tuple[EgaugeSensorEntityDescription, ...] = ( @@ -37,6 +43,7 @@ class EgaugeSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfPower.WATT, native_value_fn=lambda data, register: data.measurements[register], available_fn=lambda data, register: register in data.measurements, + supported_fn=lambda register_info: register_info.type == RegisterType.POWER, ), EgaugeSensorEntityDescription( key="energy", @@ -46,6 +53,25 @@ class EgaugeSensorEntityDescription(SensorEntityDescription): suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, native_value_fn=lambda data, register: data.counters[register], available_fn=lambda data, register: register in data.counters, + supported_fn=lambda register_info: register_info.type == RegisterType.POWER, + ), + EgaugeSensorEntityDescription( + key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + native_value_fn=lambda data, register: data.measurements[register], + available_fn=lambda data, register: register in data.measurements, + supported_fn=lambda register_info: register_info.type == RegisterType.VOLTAGE, + ), + EgaugeSensorEntityDescription( + key="current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + native_value_fn=lambda data, register: data.measurements[register], + available_fn=lambda data, register: register in data.measurements, + supported_fn=lambda register_info: register_info.type == RegisterType.CURRENT, ), ) @@ -61,7 +87,7 @@ async def async_setup_entry( EgaugeSensor(coordinator, register_name, sensor) for sensor in SENSORS for register_name, register_info in coordinator.data.register_info.items() - if register_info.type == RegisterType.POWER + if sensor.supported_fn(register_info) ) diff --git a/homeassistant/components/eheimdigital/__init__.py b/homeassistant/components/eheimdigital/__init__.py index bc8bbded18601c..dbb672dcb4b2bd 100644 --- a/homeassistant/components/eheimdigital/__init__.py +++ b/homeassistant/components/eheimdigital/__init__.py @@ -10,6 +10,7 @@ from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator PLATFORMS = [ + Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.LIGHT, Platform.NUMBER, diff --git a/homeassistant/components/eheimdigital/binary_sensor.py b/homeassistant/components/eheimdigital/binary_sensor.py new file mode 100644 index 00000000000000..82ce8c3f9fcce8 --- /dev/null +++ b/homeassistant/components/eheimdigital/binary_sensor.py @@ -0,0 +1,101 @@ +"""EHEIM Digital binary sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from eheimdigital.device import EheimDigitalDevice +from eheimdigital.reeflex import EheimDigitalReeflexUV + +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 EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator +from .entity import EheimDigitalEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class EheimDigitalBinarySensorDescription[_DeviceT: EheimDigitalDevice]( + BinarySensorEntityDescription +): + """Class describing EHEIM Digital binary sensor entities.""" + + value_fn: Callable[[_DeviceT], bool | None] + + +REEFLEX_DESCRIPTIONS: tuple[ + EheimDigitalBinarySensorDescription[EheimDigitalReeflexUV], ... +] = ( + EheimDigitalBinarySensorDescription[EheimDigitalReeflexUV]( + key="is_lighting", + translation_key="is_lighting", + value_fn=lambda device: device.is_lighting, + device_class=BinarySensorDeviceClass.LIGHT, + ), + EheimDigitalBinarySensorDescription[EheimDigitalReeflexUV]( + key="is_uvc_connected", + translation_key="is_uvc_connected", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda device: device.is_uvc_connected, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EheimDigitalConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the callbacks for the coordinator so binary sensors can be added as devices are found.""" + coordinator = entry.runtime_data + + def async_setup_device_entities( + device_address: dict[str, EheimDigitalDevice], + ) -> None: + """Set up the binary sensor entities for one or multiple devices.""" + entities: list[EheimDigitalBinarySensor[Any]] = [] + for device in device_address.values(): + if isinstance(device, EheimDigitalReeflexUV): + entities += [ + EheimDigitalBinarySensor[EheimDigitalReeflexUV]( + coordinator, device, description + ) + for description in REEFLEX_DESCRIPTIONS + ] + + async_add_entities(entities) + + coordinator.add_platform_callback(async_setup_device_entities) + async_setup_device_entities(coordinator.hub.devices) + + +class EheimDigitalBinarySensor[_DeviceT: EheimDigitalDevice]( + EheimDigitalEntity[_DeviceT], BinarySensorEntity +): + """Represent an EHEIM Digital binary sensor entity.""" + + entity_description: EheimDigitalBinarySensorDescription[_DeviceT] + + def __init__( + self, + coordinator: EheimDigitalUpdateCoordinator, + device: _DeviceT, + description: EheimDigitalBinarySensorDescription[_DeviceT], + ) -> None: + """Initialize an EHEIM Digital binary sensor entity.""" + super().__init__(coordinator, device) + self.entity_description = description + self._attr_unique_id = f"{self._device_address}_{description.key}" + + def _async_update_attrs(self) -> None: + self._attr_is_on = self.entity_description.value_fn(self._device) diff --git a/homeassistant/components/eheimdigital/coordinator.py b/homeassistant/components/eheimdigital/coordinator.py index df5475b6567aaf..61c3be363c8396 100644 --- a/homeassistant/components/eheimdigital/coordinator.py +++ b/homeassistant/components/eheimdigital/coordinator.py @@ -53,6 +53,7 @@ def __init__( main_device_added_event=self.main_device_added_event, ) self.known_devices: set[str] = set() + self.incomplete_devices: set[str] = set() self.platform_callbacks: set[AsyncSetupDeviceEntitiesCallback] = set() def add_platform_callback( @@ -70,11 +71,26 @@ async def _async_device_found( This function is called from the library whenever a new device is added. """ - if device_address not in self.known_devices: + if self.hub.devices[device_address].is_missing_data: + self.incomplete_devices.add(device_address) + return + + if ( + device_address not in self.known_devices + or device_address in self.incomplete_devices + ): for platform_callback in self.platform_callbacks: platform_callback({device_address: self.hub.devices[device_address]}) + if device_address in self.incomplete_devices: + self.incomplete_devices.remove(device_address) async def _async_receive_callback(self) -> None: + if any(self.incomplete_devices): + for device_address in self.incomplete_devices.copy(): + if not self.hub.devices[device_address].is_missing_data: + await self._async_device_found( + device_address, EheimDeviceType.VERSION_UNDEFINED + ) self.async_set_updated_data(self.hub.devices) async def _async_setup(self) -> None: diff --git a/homeassistant/components/eheimdigital/diagnostics.py b/homeassistant/components/eheimdigital/diagnostics.py index 208131beabea0b..546d1bd7989850 100644 --- a/homeassistant/components/eheimdigital/diagnostics.py +++ b/homeassistant/components/eheimdigital/diagnostics.py @@ -7,7 +7,7 @@ from .coordinator import EheimDigitalConfigEntry -TO_REDACT = {"emailAddr", "usrName"} +TO_REDACT = {"emailAddr", "usrName", "api_usrName", "api_password"} async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/eheimdigital/icons.json b/homeassistant/components/eheimdigital/icons.json index 23eca2051ddc2a..13ae0b7581478e 100644 --- a/homeassistant/components/eheimdigital/icons.json +++ b/homeassistant/components/eheimdigital/icons.json @@ -1,5 +1,19 @@ { "entity": { + "binary_sensor": { + "is_lighting": { + "default": "mdi:lightbulb-outline", + "state": { + "on": "mdi:lightbulb-on" + } + }, + "is_uvc_connected": { + "default": "mdi:lightbulb-off", + "state": { + "on": "mdi:lightbulb-outline" + } + } + }, "number": { "day_speed": { "default": "mdi:weather-sunny" diff --git a/homeassistant/components/eheimdigital/number.py b/homeassistant/components/eheimdigital/number.py index bd8d8519653edf..5c779494ffe4ca 100644 --- a/homeassistant/components/eheimdigital/number.py +++ b/homeassistant/components/eheimdigital/number.py @@ -8,6 +8,7 @@ from eheimdigital.device import EheimDigitalDevice from eheimdigital.filter import EheimDigitalFilter from eheimdigital.heater import EheimDigitalHeater +from eheimdigital.reeflex import EheimDigitalReeflexUV from eheimdigital.types import HeaterUnit from homeassistant.components.number import ( @@ -44,6 +45,47 @@ class EheimDigitalNumberDescription[_DeviceT: EheimDigitalDevice]( uom_fn: Callable[[_DeviceT], str] | None = None +REEFLEX_DESCRIPTIONS: tuple[ + EheimDigitalNumberDescription[EheimDigitalReeflexUV], ... +] = ( + EheimDigitalNumberDescription[EheimDigitalReeflexUV]( + key="daily_burn_time", + translation_key="daily_burn_time", + entity_category=EntityCategory.CONFIG, + native_step=PRECISION_WHOLE, + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=NumberDeviceClass.DURATION, + native_min_value=0, + native_max_value=1440, + value_fn=lambda device: device.daily_burn_time, + set_value_fn=lambda device, value: device.set_daily_burn_time(int(value)), + ), + EheimDigitalNumberDescription[EheimDigitalReeflexUV]( + key="booster_time", + translation_key="booster_time", + entity_category=EntityCategory.CONFIG, + native_step=PRECISION_WHOLE, + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=NumberDeviceClass.DURATION, + native_min_value=0, + native_max_value=20160, + value_fn=lambda device: device.booster_time, + set_value_fn=lambda device, value: device.set_booster_time(int(value)), + ), + EheimDigitalNumberDescription[EheimDigitalReeflexUV]( + key="pause_time", + translation_key="pause_time", + entity_category=EntityCategory.CONFIG, + native_step=PRECISION_WHOLE, + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=NumberDeviceClass.DURATION, + native_min_value=0, + native_max_value=20160, + value_fn=lambda device: device.pause_time, + set_value_fn=lambda device, value: device.set_pause_time(int(value)), + ), +) + FILTER_DESCRIPTIONS: tuple[EheimDigitalNumberDescription[EheimDigitalFilter], ...] = ( EheimDigitalNumberDescription[EheimDigitalFilter]( key="high_pulse_time", @@ -189,6 +231,13 @@ def async_setup_device_entities( ) for description in HEATER_DESCRIPTIONS ) + if isinstance(device, EheimDigitalReeflexUV): + entities.extend( + EheimDigitalNumber[EheimDigitalReeflexUV]( + coordinator, device, description + ) + for description in REEFLEX_DESCRIPTIONS + ) entities.extend( EheimDigitalNumber[EheimDigitalDevice](coordinator, device, description) for description in GENERAL_DESCRIPTIONS diff --git a/homeassistant/components/eheimdigital/select.py b/homeassistant/components/eheimdigital/select.py index 5ba9de28e8da09..47abc924bf0509 100644 --- a/homeassistant/components/eheimdigital/select.py +++ b/homeassistant/components/eheimdigital/select.py @@ -7,9 +7,11 @@ from eheimdigital.classic_vario import EheimDigitalClassicVario from eheimdigital.device import EheimDigitalDevice from eheimdigital.filter import EheimDigitalFilter +from eheimdigital.reeflex import EheimDigitalReeflexUV from eheimdigital.types import ( FilterMode, FilterModeProf, + ReeflexMode, UnitOfMeasurement as EheimDigitalUnitOfMeasurement, ) @@ -36,6 +38,20 @@ class EheimDigitalSelectDescription[_DeviceT: EheimDigitalDevice]( set_value_fn: Callable[[_DeviceT, str], Awaitable[None] | None] +REEFLEX_DESCRIPTIONS: tuple[ + EheimDigitalSelectDescription[EheimDigitalReeflexUV], ... +] = ( + EheimDigitalSelectDescription[EheimDigitalReeflexUV]( + key="mode", + translation_key="mode", + value_fn=lambda device: device.mode.name.lower(), + set_value_fn=( + lambda device, value: device.set_mode(ReeflexMode[value.upper()]) + ), + options=[name.lower() for name in ReeflexMode.__members__], + ), +) + FILTER_DESCRIPTIONS: tuple[EheimDigitalSelectDescription[EheimDigitalFilter], ...] = ( EheimDigitalSelectDescription[EheimDigitalFilter]( key="filter_mode", @@ -176,6 +192,13 @@ def async_setup_device_entities( EheimDigitalFilterSelect(coordinator, device, description) for description in FILTER_DESCRIPTIONS ) + if isinstance(device, EheimDigitalReeflexUV): + entities.extend( + EheimDigitalSelect[EheimDigitalReeflexUV]( + coordinator, device, description + ) + for description in REEFLEX_DESCRIPTIONS + ) async_add_entities(entities) diff --git a/homeassistant/components/eheimdigital/strings.json b/homeassistant/components/eheimdigital/strings.json index 12b0f3b48daa90..f02f33763d8549 100644 --- a/homeassistant/components/eheimdigital/strings.json +++ b/homeassistant/components/eheimdigital/strings.json @@ -33,6 +33,17 @@ } }, "entity": { + "binary_sensor": { + "is_lighting": { + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + }, + "is_uvc_connected": { + "name": "UVC lamp connected" + } + }, "climate": { "heater": { "state_attributes": { @@ -58,6 +69,12 @@ } }, "number": { + "booster_time": { + "name": "Booster duration" + }, + "daily_burn_time": { + "name": "Daily burn duration" + }, "day_speed": { "name": "Day speed" }, @@ -76,6 +93,7 @@ "night_temperature_offset": { "name": "Night temperature offset" }, + "pause_time": { "name": "Pause duration" }, "system_led": { "name": "System LED brightness" }, @@ -108,6 +126,10 @@ "manual_speed": { "name": "Manual speed" }, + "mode": { + "name": "Operation mode", + "state": { "constant": "Constant", "daycycle": "Daycycle" } + }, "night_speed": { "name": "Night speed" } @@ -127,9 +149,18 @@ "operating_time": { "name": "Operating time" }, + "remaining_booster_time": { + "name": "Remaining booster time" + }, + "remaining_pause_time": { + "name": "Remaining pause time" + }, "service_hours": { "name": "Remaining hours until service" }, + "time_until_next_service": { + "name": "Time until next service" + }, "turn_feeding_time": { "name": "Remaining off time after feeding" }, @@ -137,12 +168,26 @@ "name": "Remaining off time" } }, + "switch": { + "booster": { + "name": "Booster" + }, + "expert": { + "name": "Expert mode" + }, + "pause": { + "name": "Pause" + } + }, "time": { "day_start_time": { "name": "Day start time" }, "night_start_time": { "name": "Night start time" + }, + "start_time": { + "name": "Start time" } } }, diff --git a/homeassistant/components/eheimdigital/switch.py b/homeassistant/components/eheimdigital/switch.py index ccbaa4b4ed27f7..b25745d2dafcaa 100644 --- a/homeassistant/components/eheimdigital/switch.py +++ b/homeassistant/components/eheimdigital/switch.py @@ -1,12 +1,16 @@ """EHEIM Digital switches.""" +from collections.abc import Awaitable, Callable +from dataclasses import dataclass from typing import Any, override from eheimdigital.classic_vario import EheimDigitalClassicVario from eheimdigital.device import EheimDigitalDevice from eheimdigital.filter import EheimDigitalFilter +from eheimdigital.reeflex import EheimDigitalReeflexUV -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -17,6 +21,50 @@ PARALLEL_UPDATES = 0 +@dataclass(frozen=True, kw_only=True) +class EheimDigitalSwitchDescription[_DeviceT: EheimDigitalDevice]( + SwitchEntityDescription +): + """Class describing EHEIM Digital switch entities.""" + + is_on_fn: Callable[[_DeviceT], bool] + set_fn: Callable[[_DeviceT, bool], Awaitable[None]] + + +REEFLEX_DESCRIPTIONS: tuple[ + EheimDigitalSwitchDescription[EheimDigitalReeflexUV], ... +] = ( + EheimDigitalSwitchDescription[EheimDigitalReeflexUV]( + key="active", + name=None, + entity_category=EntityCategory.CONFIG, + is_on_fn=lambda device: device.is_active, + set_fn=lambda device, value: device.set_active(active=value), + ), + EheimDigitalSwitchDescription[EheimDigitalReeflexUV]( + key="pause", + translation_key="pause", + entity_category=EntityCategory.CONFIG, + is_on_fn=lambda device: device.pause, + set_fn=lambda device, value: device.set_pause(pause=value), + ), + EheimDigitalSwitchDescription[EheimDigitalReeflexUV]( + key="booster", + translation_key="booster", + entity_category=EntityCategory.CONFIG, + is_on_fn=lambda device: device.booster, + set_fn=lambda device, value: device.set_booster(active=value), + ), + EheimDigitalSwitchDescription[EheimDigitalReeflexUV]( + key="expert", + translation_key="expert", + entity_category=EntityCategory.CONFIG, + is_on_fn=lambda device: device.expert, + set_fn=lambda device, value: device.set_expert(active=value), + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: EheimDigitalConfigEntry, @@ -32,7 +80,14 @@ def async_setup_device_entities( entities: list[SwitchEntity] = [] for device in device_address.values(): if isinstance(device, (EheimDigitalClassicVario, EheimDigitalFilter)): - entities.append(EheimDigitalFilterSwitch(coordinator, device)) # noqa: PERF401 + entities.append(EheimDigitalFilterSwitch(coordinator, device)) + if isinstance(device, EheimDigitalReeflexUV): + entities.extend( + EheimDigitalSwitch[EheimDigitalReeflexUV]( + coordinator, device, description + ) + for description in REEFLEX_DESCRIPTIONS + ) async_add_entities(entities) @@ -40,6 +95,39 @@ def async_setup_device_entities( async_setup_device_entities(coordinator.hub.devices) +class EheimDigitalSwitch[_DeviceT: EheimDigitalDevice]( + EheimDigitalEntity[_DeviceT], SwitchEntity +): + """Represent a EHEIM Digital switch entity.""" + + entity_description: EheimDigitalSwitchDescription[_DeviceT] + + def __init__( + self, + coordinator: EheimDigitalUpdateCoordinator, + device: _DeviceT, + description: EheimDigitalSwitchDescription[_DeviceT], + ) -> None: + """Initialize an EHEIM Digital switch entity.""" + super().__init__(coordinator, device) + self.entity_description = description + self._attr_unique_id = f"{self._device_address}_{description.key}" + + @exception_handler + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the switch.""" + return await self.entity_description.set_fn(self._device, True) + + @exception_handler + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the switch.""" + return await self.entity_description.set_fn(self._device, False) + + @override + def _async_update_attrs(self) -> None: + self._attr_is_on = self.entity_description.is_on_fn(self._device) + + class EheimDigitalFilterSwitch( EheimDigitalEntity[EheimDigitalClassicVario | EheimDigitalFilter], SwitchEntity ): diff --git a/homeassistant/components/eheimdigital/time.py b/homeassistant/components/eheimdigital/time.py index 4a5ab7f8bd822b..ba83e191824670 100644 --- a/homeassistant/components/eheimdigital/time.py +++ b/homeassistant/components/eheimdigital/time.py @@ -9,6 +9,7 @@ from eheimdigital.device import EheimDigitalDevice from eheimdigital.filter import EheimDigitalFilter from eheimdigital.heater import EheimDigitalHeater +from eheimdigital.reeflex import EheimDigitalReeflexUV from homeassistant.components.time import TimeEntity, TimeEntityDescription from homeassistant.const import EntityCategory @@ -29,6 +30,16 @@ class EheimDigitalTimeDescription[_DeviceT: EheimDigitalDevice](TimeEntityDescri set_value_fn: Callable[[_DeviceT, time], Awaitable[None]] +REEFLEX_DESCRIPTIONS: tuple[EheimDigitalTimeDescription[EheimDigitalReeflexUV], ...] = ( + EheimDigitalTimeDescription[EheimDigitalReeflexUV]( + key="start_time", + translation_key="start_time", + entity_category=EntityCategory.CONFIG, + value_fn=lambda device: device.start_time, + set_value_fn=lambda device, value: device.set_day_start_time(value), + ), +) + FILTER_DESCRIPTIONS: tuple[EheimDigitalTimeDescription[EheimDigitalFilter], ...] = ( EheimDigitalTimeDescription[EheimDigitalFilter]( key="day_start_time", @@ -118,6 +129,13 @@ def async_setup_device_entities( ) for description in HEATER_DESCRIPTIONS ) + if isinstance(device, EheimDigitalReeflexUV): + entities.extend( + EheimDigitalTime[EheimDigitalReeflexUV]( + coordinator, device, description + ) + for description in REEFLEX_DESCRIPTIONS + ) async_add_entities(entities) diff --git a/homeassistant/components/elv/switch.py b/homeassistant/components/elv/switch.py index e790873e368d91..c4645dc39b357d 100644 --- a/homeassistant/components/elv/switch.py +++ b/homeassistant/components/elv/switch.py @@ -16,8 +16,6 @@ _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = "PCA 301" - def setup_platform( hass: HomeAssistant, @@ -54,26 +52,9 @@ class SmartPlugSwitch(SwitchEntity): def __init__(self, pca, device_id): """Initialize the switch.""" self._device_id = device_id - self._name = "PCA 301" - self._state = None - self._available = True + self._attr_name = "PCA 301" self._pca = pca - @property - def name(self): - """Return the name of the Smart Plug, if any.""" - return self._name - - @property - def available(self) -> bool: - """Return if switch is available.""" - return self._available - - @property - def is_on(self): - """Return true if switch is on.""" - return self._state - def turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" self._pca.turn_on(self._device_id) @@ -85,10 +66,10 @@ def turn_off(self, **kwargs: Any) -> None: def update(self) -> None: """Update the PCA switch's state.""" try: - self._state = self._pca.get_state(self._device_id) - self._available = True + self._attr_is_on = self._pca.get_state(self._device_id) + self._attr_available = True except OSError as ex: - if self._available: + if self._attr_available: _LOGGER.warning("Could not read state for %s: %s", self.name, ex) - self._available = False + self._attr_available = False diff --git a/homeassistant/components/energy/data.py b/homeassistant/components/energy/data.py index 16d8158f7e2d6f..7484cc4a5046c8 100644 --- a/homeassistant/components/energy/data.py +++ b/homeassistant/components/energy/data.py @@ -9,13 +9,13 @@ import voluptuous as vol -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback, valid_entity_id from homeassistant.helpers import config_validation as cv, singleton, storage from .const import DOMAIN STORAGE_VERSION = 1 -STORAGE_MINOR_VERSION = 2 +STORAGE_MINOR_VERSION = 3 STORAGE_KEY = DOMAIN @@ -92,8 +92,11 @@ class GridPowerSourceType(TypedDict, total=False): power_config: PowerConfig -class GridSourceType(TypedDict): - """Dictionary holding the source of grid energy consumption.""" +class LegacyGridSourceType(TypedDict): + """Legacy dictionary holding the source of grid energy consumption. + + This format is deprecated and will be migrated to GridSourceType. + """ type: Literal["grid"] @@ -104,6 +107,40 @@ class GridSourceType(TypedDict): cost_adjustment_day: float +class GridSourceType(TypedDict): + """Dictionary holding a unified grid connection (like batteries). + + Each grid connection represents a single import/export pair with + optional power tracking. Multiple grid sources are allowed. + """ + + type: Literal["grid"] + + # Import meter - kWh consumed from grid + # Can be None for export-only or power-only grids migrated from legacy format + stat_energy_from: str | None + + # Export meter (optional) - kWh returned to grid (solar/battery export) + stat_energy_to: str | None + + # Cost tracking for import + stat_cost: str | None # statistic_id of costs ($) incurred + entity_energy_price: str | None # entity_id providing price ($/kWh) + number_energy_price: float | None # Fixed price ($/kWh) + + # Compensation tracking for export + stat_compensation: str | None # statistic_id of compensation ($) received + entity_energy_price_export: str | None # entity_id providing export price ($/kWh) + number_energy_price_export: float | None # Fixed export price ($/kWh) + + # Power measurement (optional) + # positive when consuming from grid, negative when exporting + stat_rate: NotRequired[str] + power_config: NotRequired[PowerConfig] + + cost_adjustment_day: float + + class SolarSourceType(TypedDict): """Dictionary holding the source of energy production.""" @@ -136,6 +173,9 @@ class GasSourceType(TypedDict): stat_energy_from: str + # Instantaneous flow rate: m³/h, L/min, etc. + stat_rate: NotRequired[str] + # statistic_id of costs ($) incurred from the gas meter # If set to None and entity_energy_price or number_energy_price are configured, # an EnergyCostSensor will be automatically created @@ -153,6 +193,9 @@ class WaterSourceType(TypedDict): stat_energy_from: str + # Instantaneous flow rate: L/min, gal/min, m³/h, etc. + stat_rate: NotRequired[str] + # statistic_id of costs ($) incurred from the water meter # If set to None and entity_energy_price or number_energy_price are configured, # an EnergyCostSensor will be automatically created @@ -201,6 +244,38 @@ class EnergyPreferencesUpdate(EnergyPreferences, total=False): """all types optional.""" +def _reject_price_for_external_stat( + *, + stat_key: str, + entity_price_key: str = "entity_energy_price", + number_price_key: str = "number_energy_price", + cost_stat_key: str = "stat_cost", +) -> Callable[[dict[str, Any]], dict[str, Any]]: + """Return a validator that rejects entity/number price for external statistics. + + Only rejects when the cost/compensation stat is not already set, since + price fields are ignored when a cost stat is provided. + """ + + def validate(val: dict[str, Any]) -> dict[str, Any]: + stat_id = val.get(stat_key) + if stat_id is not None and not valid_entity_id(stat_id): + if val.get(cost_stat_key) is not None: + # Cost stat is already set; price fields are ignored, so allow. + return val + if ( + val.get(entity_price_key) is not None + or val.get(number_price_key) is not None + ): + raise vol.Invalid( + "Entity or number price is not supported for external" + f" statistics. Use {cost_stat_key} instead" + ) + return val + + return validate + + def _flow_from_ensure_single_price( val: FlowFromGridSourceType, ) -> FlowFromGridSourceType: @@ -225,19 +300,25 @@ def _flow_from_ensure_single_price( vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None), } ), + _reject_price_for_external_stat(stat_key="stat_energy_from"), _flow_from_ensure_single_price, ) -FLOW_TO_GRID_SOURCE_SCHEMA = vol.Schema( - { - vol.Required("stat_energy_to"): str, - vol.Optional("stat_compensation"): vol.Any(str, None), - # entity_energy_to was removed in HA Core 2022.10 - vol.Remove("entity_energy_to"): vol.Any(str, None), - vol.Optional("entity_energy_price"): vol.Any(str, None), - vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None), - } +FLOW_TO_GRID_SOURCE_SCHEMA = vol.All( + vol.Schema( + { + vol.Required("stat_energy_to"): str, + vol.Optional("stat_compensation"): vol.Any(str, None), + # entity_energy_to was removed in HA Core 2022.10 + vol.Remove("entity_energy_to"): vol.Any(str, None), + vol.Optional("entity_energy_price"): vol.Any(str, None), + vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None), + } + ), + _reject_price_for_external_stat( + stat_key="stat_energy_to", cost_stat_key="stat_compensation" + ), ) @@ -308,23 +389,84 @@ def validate_uniqueness( return validate_uniqueness -GRID_SOURCE_SCHEMA = vol.Schema( - { - vol.Required("type"): "grid", - vol.Required("flow_from"): vol.All( - [FLOW_FROM_GRID_SOURCE_SCHEMA], - _generate_unique_value_validator("stat_energy_from"), - ), - vol.Required("flow_to"): vol.All( - [FLOW_TO_GRID_SOURCE_SCHEMA], - _generate_unique_value_validator("stat_energy_to"), - ), - vol.Optional("power"): vol.All( - [GRID_POWER_SOURCE_SCHEMA], - _generate_unique_value_validator("stat_rate"), - ), - vol.Required("cost_adjustment_day"): vol.Coerce(float), - } +def _grid_ensure_single_price_import( + val: dict[str, Any], +) -> dict[str, Any]: + """Ensure we use a single price source for import.""" + if ( + val.get("entity_energy_price") is not None + and val.get("number_energy_price") is not None + ): + raise vol.Invalid("Define either an entity or a fixed number for import price") + return val + + +def _grid_ensure_single_price_export( + val: dict[str, Any], +) -> dict[str, Any]: + """Ensure we use a single price source for export.""" + if ( + val.get("entity_energy_price_export") is not None + and val.get("number_energy_price_export") is not None + ): + raise vol.Invalid("Define either an entity or a fixed number for export price") + return val + + +def _grid_ensure_at_least_one_stat( + val: dict[str, Any], +) -> dict[str, Any]: + """Ensure at least one of import, export, or power is configured.""" + if ( + val.get("stat_energy_from") is None + and val.get("stat_energy_to") is None + and val.get("stat_rate") is None + and val.get("power_config") is None + ): + raise vol.Invalid( + "Grid must have at least one of: import meter, export meter, or power sensor" + ) + return val + + +GRID_SOURCE_SCHEMA = vol.All( + vol.Schema( + { + vol.Required("type"): "grid", + # Import meter (can be None for export-only grids from legacy migration) + vol.Optional("stat_energy_from", default=None): vol.Any(str, None), + # Export meter (optional) + vol.Optional("stat_energy_to", default=None): vol.Any(str, None), + # Import cost tracking + vol.Optional("stat_cost", default=None): vol.Any(str, None), + vol.Optional("entity_energy_price", default=None): vol.Any(str, None), + vol.Optional("number_energy_price", default=None): vol.Any( + vol.Coerce(float), None + ), + # Export compensation tracking + vol.Optional("stat_compensation", default=None): vol.Any(str, None), + vol.Optional("entity_energy_price_export", default=None): vol.Any( + str, None + ), + vol.Optional("number_energy_price_export", default=None): vol.Any( + vol.Coerce(float), None + ), + # Power measurement (optional) + vol.Optional("stat_rate"): str, + vol.Optional("power_config"): POWER_CONFIG_SCHEMA, + vol.Required("cost_adjustment_day"): vol.Coerce(float), + } + ), + _reject_price_for_external_stat(stat_key="stat_energy_from"), + _reject_price_for_external_stat( + stat_key="stat_energy_to", + entity_price_key="entity_energy_price_export", + number_price_key="number_energy_price_export", + cost_stat_key="stat_compensation", + ), + _grid_ensure_single_price_import, + _grid_ensure_single_price_export, + _grid_ensure_at_least_one_stat, ) SOLAR_SOURCE_SCHEMA = vol.Schema( { @@ -345,34 +487,80 @@ def validate_uniqueness( vol.Optional("power_config"): POWER_CONFIG_SCHEMA, } ) -GAS_SOURCE_SCHEMA = vol.Schema( - { - vol.Required("type"): "gas", - vol.Required("stat_energy_from"): str, - vol.Optional("stat_cost"): vol.Any(str, None), - # entity_energy_from was removed in HA Core 2022.10 - vol.Remove("entity_energy_from"): vol.Any(str, None), - vol.Optional("entity_energy_price"): vol.Any(str, None), - vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None), - } + + +GAS_SOURCE_SCHEMA = vol.All( + vol.Schema( + { + vol.Required("type"): "gas", + vol.Required("stat_energy_from"): str, + vol.Optional("stat_rate"): str, + vol.Optional("stat_cost"): vol.Any(str, None), + # entity_energy_from was removed in HA Core 2022.10 + vol.Remove("entity_energy_from"): vol.Any(str, None), + vol.Optional("entity_energy_price"): vol.Any(str, None), + vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None), + } + ), + _reject_price_for_external_stat(stat_key="stat_energy_from"), ) -WATER_SOURCE_SCHEMA = vol.Schema( - { - vol.Required("type"): "water", - vol.Required("stat_energy_from"): str, - vol.Optional("stat_cost"): vol.Any(str, None), - vol.Optional("entity_energy_price"): vol.Any(str, None), - vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None), - } +WATER_SOURCE_SCHEMA = vol.All( + vol.Schema( + { + vol.Required("type"): "water", + vol.Required("stat_energy_from"): str, + vol.Optional("stat_rate"): str, + vol.Optional("stat_cost"): vol.Any(str, None), + vol.Optional("entity_energy_price"): vol.Any(str, None), + vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None), + } + ), + _reject_price_for_external_stat(stat_key="stat_energy_from"), ) def check_type_limits(value: list[SourceType]) -> list[SourceType]: """Validate that we don't have too many of certain types.""" - types = Counter([val["type"] for val in value]) + # Currently no type limits - multiple grid sources are allowed (like batteries) + return value + + +def _validate_grid_stat_uniqueness(value: list[SourceType]) -> list[SourceType]: + """Validate that grid statistics are unique across all sources.""" + seen_import: set[str] = set() + seen_export: set[str] = set() + seen_rate: set[str] = set() + + for source in value: + if source.get("type") != "grid": + continue - if types.get("grid", 0) > 1: - raise vol.Invalid("You cannot have more than 1 grid source") + # Cast to GridSourceType since we've filtered for grid type + grid_source: GridSourceType = source # type: ignore[assignment] + + # Check import meter uniqueness + if (stat_from := grid_source.get("stat_energy_from")) is not None: + if stat_from in seen_import: + raise vol.Invalid( + f"Import meter {stat_from} is used in multiple grid connections" + ) + seen_import.add(stat_from) + + # Check export meter uniqueness + if (stat_to := grid_source.get("stat_energy_to")) is not None: + if stat_to in seen_export: + raise vol.Invalid( + f"Export meter {stat_to} is used in multiple grid connections" + ) + seen_export.add(stat_to) + + # Check power stat uniqueness + if (stat_rate := grid_source.get("stat_rate")) is not None: + if stat_rate in seen_rate: + raise vol.Invalid( + f"Power stat {stat_rate} is used in multiple grid connections" + ) + seen_rate.add(stat_rate) return value @@ -393,6 +581,7 @@ def check_type_limits(value: list[SourceType]) -> list[SourceType]: ] ), check_type_limits, + _validate_grid_stat_uniqueness, ) DEVICE_CONSUMPTION_SCHEMA = vol.Schema( @@ -405,6 +594,82 @@ def check_type_limits(value: list[SourceType]) -> list[SourceType]: ) +def _migrate_legacy_grid_to_unified( + old_grid: dict[str, Any], +) -> list[dict[str, Any]]: + """Migrate legacy grid format (flow_from/flow_to/power arrays) to unified format. + + Each grid connection can have any combination of import, export, and power - + all are optional as long as at least one is configured. + + Migration pairs arrays by index position: + - flow_from[i], flow_to[i], and power[i] combine into grid connection i + - If arrays have different lengths, missing entries get None for that field + - The number of grid connections equals max(len(flow_from), len(flow_to), len(power)) + """ + flow_from = old_grid.get("flow_from", []) + flow_to = old_grid.get("flow_to", []) + power_list = old_grid.get("power", []) + cost_adj = old_grid.get("cost_adjustment_day", 0.0) + + new_sources: list[dict[str, Any]] = [] + # Number of grid connections = max length across all three arrays + # If all arrays are empty, don't create any grid sources + max_len = max(len(flow_from), len(flow_to), len(power_list)) + if max_len == 0: + return [] + + for i in range(max_len): + source: dict[str, Any] = { + "type": "grid", + "cost_adjustment_day": cost_adj, + } + + # Import fields from flow_from + if i < len(flow_from): + ff = flow_from[i] + source["stat_energy_from"] = ff.get("stat_energy_from") or None + source["stat_cost"] = ff.get("stat_cost") + source["entity_energy_price"] = ff.get("entity_energy_price") + source["number_energy_price"] = ff.get("number_energy_price") + else: + # Export-only entry - set import to None (validation will flag this) + source["stat_energy_from"] = None + source["stat_cost"] = None + source["entity_energy_price"] = None + source["number_energy_price"] = None + + # Export fields from flow_to + if i < len(flow_to): + ft = flow_to[i] + source["stat_energy_to"] = ft.get("stat_energy_to") + source["stat_compensation"] = ft.get("stat_compensation") + source["entity_energy_price_export"] = ft.get("entity_energy_price") + source["number_energy_price_export"] = ft.get("number_energy_price") + else: + source["stat_energy_to"] = None + source["stat_compensation"] = None + source["entity_energy_price_export"] = None + source["number_energy_price_export"] = None + + # Power config at index i goes to grid connection at index i + if i < len(power_list): + power = power_list[i] + if "power_config" in power: + source["power_config"] = power["power_config"] + if "stat_rate" in power: + source["stat_rate"] = power["stat_rate"] + + new_sources.append(source) + + return new_sources + + +def _is_legacy_grid_format(source: dict[str, Any]) -> bool: + """Check if a grid source is in the legacy format.""" + return source.get("type") == "grid" and "flow_from" in source + + class _EnergyPreferencesStore(storage.Store[EnergyPreferences]): """Energy preferences store with migration support.""" @@ -419,6 +684,18 @@ async def _async_migrate_func( if old_major_version == 1 and old_minor_version < 2: # Add device_consumption_water field if it doesn't exist data.setdefault("device_consumption_water", []) + + if old_major_version == 1 and old_minor_version < 3: + # Migrate legacy grid format to unified format + new_sources: list[dict[str, Any]] = [] + for source in data.get("energy_sources", []): + if _is_legacy_grid_format(source): + # Convert legacy grid to multiple unified grid sources + new_sources.extend(_migrate_legacy_grid_to_unified(source)) + else: + new_sources.append(source) + data["energy_sources"] = new_sources + return data @@ -516,27 +793,18 @@ def _process_grid_power( source: GridSourceType, generate_entity_id: Callable[[str, PowerConfig], str], ) -> GridSourceType: - """Set stat_rate for grid power sources if power_config is specified.""" - if "power" not in source: + """Set stat_rate for grid if power_config is specified.""" + if "power_config" not in source: return source - processed_power: list[GridPowerSourceType] = [] - for power in source["power"]: - if "power_config" in power: - config = power["power_config"] + config = source["power_config"] - # If power_config has stat_rate (standard), just use it directly - if "stat_rate" in config: - processed_power.append({**power, "stat_rate": config["stat_rate"]}) - else: - # For inverted or two-sensor config, set stat_rate to generated entity_id - processed_power.append( - {**power, "stat_rate": generate_entity_id("grid", config)} - ) - else: - processed_power.append(power) - - return {**source, "power": processed_power} + # If power_config has stat_rate (standard), just use it directly + if "stat_rate" in config: + return {**source, "stat_rate": config["stat_rate"]} + + # For inverted or two-sensor config, set stat_rate to the generated entity_id + return {**source, "stat_rate": generate_entity_id("grid", config)} @callback def async_listen_updates(self, update_listener: Callable[[], Awaitable]) -> None: diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 3a512dc5211846..e228e11d00d777 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -94,22 +94,15 @@ class SourceAdapter: SOURCE_ADAPTERS: Final = ( + # Grid import cost (unified format) SourceAdapter( "grid", - "flow_from", + None, # No flow_type - unified format "stat_energy_from", "stat_cost", "Cost", "cost", ), - SourceAdapter( - "grid", - "flow_to", - "stat_energy_to", - "stat_compensation", - "Compensation", - "compensation", - ), SourceAdapter( "gas", None, @@ -128,6 +121,16 @@ class SourceAdapter: ), ) +# Separate adapter for grid export compensation (needs different price field) +GRID_EXPORT_ADAPTER: Final = SourceAdapter( + "grid", + None, # No flow_type - unified format + "stat_energy_to", + "stat_compensation", + "Compensation", + "compensation", +) + class EntityNotFoundError(HomeAssistantError): """When a referenced entity was not found.""" @@ -183,22 +186,20 @@ async def finish() -> None: if adapter.source_type != energy_source["type"]: continue - if adapter.flow_type is None: - self._process_sensor_data( - adapter, - energy_source, - to_add, - to_remove, - ) - continue + self._process_sensor_data( + adapter, + energy_source, + to_add, + to_remove, + ) - for flow in energy_source[adapter.flow_type]: # type: ignore[typeddict-item] - self._process_sensor_data( - adapter, - flow, - to_add, - to_remove, - ) + # Handle grid export compensation (unified format uses different price fields) + if energy_source["type"] == "grid": + self._process_grid_export_sensor( + energy_source, + to_add, + to_remove, + ) # Process power sensors for battery and grid sources self._process_power_sensor_data( @@ -222,11 +223,16 @@ def _process_sensor_data( if config.get(adapter.total_money_key) is not None: return - key = (adapter.source_type, adapter.flow_type, config[adapter.stat_energy_key]) + # Skip if the energy stat is not configured (e.g., export-only or power-only grids) + stat_energy = config.get(adapter.stat_energy_key) + if not stat_energy: + return + + key = (adapter.source_type, adapter.flow_type, stat_energy) # Make sure the right data is there # If the entity existed, we don't pop it from to_remove so it's removed - if not valid_entity_id(config[adapter.stat_energy_key]) or ( + if not valid_entity_id(stat_energy) or ( config.get("entity_energy_price") is None and config.get("number_energy_price") is None ): @@ -242,6 +248,56 @@ def _process_sensor_data( ) to_add.append(self.current_entities[key]) + @callback + def _process_grid_export_sensor( + self, + config: Mapping[str, Any], + to_add: list[EnergyCostSensor | EnergyPowerSensor], + to_remove: dict[tuple[str, str | None, str], EnergyCostSensor], + ) -> None: + """Process grid export compensation sensor (unified format). + + The unified grid format uses different field names for export pricing: + - entity_energy_price_export instead of entity_energy_price + - number_energy_price_export instead of number_energy_price + """ + # No export meter configured + stat_energy_to = config.get("stat_energy_to") + if stat_energy_to is None: + return + + # Already have a compensation stat + if config.get("stat_compensation") is not None: + return + + key = ("grid", None, stat_energy_to) + + # Check for export pricing fields (different names in unified format) + if not valid_entity_id(stat_energy_to) or ( + config.get("entity_energy_price_export") is None + and config.get("number_energy_price_export") is None + ): + return + + # Create a config wrapper that maps the sell price fields to standard names + # so EnergyCostSensor can use them + export_config: dict[str, Any] = { + "stat_energy_to": stat_energy_to, + "stat_compensation": config.get("stat_compensation"), + "entity_energy_price": config.get("entity_energy_price_export"), + "number_energy_price": config.get("number_energy_price_export"), + } + + if current_entity := to_remove.pop(key, None): + current_entity.update_config(export_config) + return + + self.current_entities[key] = EnergyCostSensor( + GRID_EXPORT_ADAPTER, + export_config, + ) + to_add.append(self.current_entities[key]) + @callback def _process_power_sensor_data( self, @@ -252,21 +308,14 @@ def _process_power_sensor_data( """Process power sensor data for battery and grid sources.""" source_type = energy_source.get("type") - if source_type == "battery": + if source_type in ("battery", "grid"): + # Both battery and grid now use unified format with power_config at top level power_config = energy_source.get("power_config") if power_config and self._needs_power_sensor(power_config): self._create_or_keep_power_sensor( source_type, power_config, to_add, to_remove ) - elif source_type == "grid": - for power in energy_source.get("power", []): - power_config = power.get("power_config") - if power_config and self._needs_power_sensor(power_config): - self._create_or_keep_power_sensor( - source_type, power_config, to_add, to_remove - ) - @staticmethod def _needs_power_sensor(power_config: PowerConfig) -> bool: """Check if power_config needs a transform sensor.""" @@ -312,6 +361,17 @@ class EnergyCostSensor(SensorEntity): This is intended as a fallback for when no specific cost sensor is available for the utility. + + Expected config fields (from adapter or export_config wrapper): + - stat_energy_key (via adapter): Key to get the energy statistic ID + - total_money_key (via adapter): Key to get the existing cost/compensation stat + - entity_energy_price: Entity ID providing price per unit (e.g., $/kWh) + - number_energy_price: Fixed price per unit + + Note: For grid export compensation, the unified format uses different field names + (entity_energy_price_export, number_energy_price_export). The _process_grid_export_sensor + method in SensorManager creates a wrapper config that maps these to the standard + field names (entity_energy_price, number_energy_price) so this class can use them. """ _attr_entity_registry_visible_default = False diff --git a/homeassistant/components/energy/strings.json b/homeassistant/components/energy/strings.json index 28beffdea7610f..e9f7329d6cb271 100644 --- a/homeassistant/components/energy/strings.json +++ b/homeassistant/components/energy/strings.json @@ -44,6 +44,10 @@ "description": "[%key:component::energy::issues::entity_unexpected_unit_energy_price::description%]", "title": "[%key:component::energy::issues::entity_unexpected_unit_energy::title%]" }, + "entity_unexpected_unit_volume_flow_rate": { + "description": "The following entities do not have an expected unit of measurement (either of {flow_rate_units}):", + "title": "[%key:component::energy::issues::entity_unexpected_unit_energy::title%]" + }, "entity_unexpected_unit_water": { "description": "The following entities do not have the expected unit of measurement (either of {water_units}):", "title": "[%key:component::energy::issues::entity_unexpected_unit_energy::title%]" diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index 0508da5295f582..2e4f2715dd8fd7 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -14,6 +14,7 @@ UnitOfEnergy, UnitOfPower, UnitOfVolume, + UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant, callback, valid_entity_id @@ -28,6 +29,11 @@ POWER_USAGE_UNITS: dict[str, tuple[UnitOfPower, ...]] = { sensor.SensorDeviceClass.POWER: tuple(UnitOfPower) } +VOLUME_FLOW_RATE_DEVICE_CLASSES = (sensor.SensorDeviceClass.VOLUME_FLOW_RATE,) +VOLUME_FLOW_RATE_UNITS: dict[str, tuple[UnitOfVolumeFlowRate, ...]] = { + sensor.SensorDeviceClass.VOLUME_FLOW_RATE: tuple(UnitOfVolumeFlowRate) +} +VOLUME_FLOW_RATE_UNIT_ERROR = "entity_unexpected_unit_volume_flow_rate" ENERGY_PRICE_UNITS = tuple( f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units @@ -109,6 +115,12 @@ def _get_placeholders(hass: HomeAssistant, issue_type: str) -> dict[str, str] | return { "price_units": ", ".join(f"{currency}{unit}" for unit in WATER_PRICE_UNITS), } + if issue_type == VOLUME_FLOW_RATE_UNIT_ERROR: + return { + "flow_rate_units": ", ".join( + VOLUME_FLOW_RATE_UNITS[sensor.SensorDeviceClass.VOLUME_FLOW_RATE] + ), + } return None @@ -401,16 +413,20 @@ def _validate_grid_source( source_result: ValidationIssues, validate_calls: list[functools.partial[None]], ) -> None: - """Validate grid energy source.""" - flow_from: data.FlowFromGridSourceType - for flow_from in source["flow_from"]: - wanted_statistics_metadata.add(flow_from["stat_energy_from"]) + """Validate grid energy source (unified format).""" + stat_energy_from = source.get("stat_energy_from") + stat_energy_to = source.get("stat_energy_to") + stat_rate = source.get("stat_rate") + + # Validate import meter (optional) + if stat_energy_from: + wanted_statistics_metadata.add(stat_energy_from) validate_calls.append( functools.partial( _async_validate_usage_stat, hass, statistics_metadata, - flow_from["stat_energy_from"], + stat_energy_from, ENERGY_USAGE_DEVICE_CLASSES, ENERGY_USAGE_UNITS, ENERGY_UNIT_ERROR, @@ -418,7 +434,8 @@ def _validate_grid_source( ) ) - if (stat_cost := flow_from.get("stat_cost")) is not None: + # Validate import cost tracking (only if import meter exists) + if (stat_cost := source.get("stat_cost")) is not None: wanted_statistics_metadata.add(stat_cost) validate_calls.append( functools.partial( @@ -429,7 +446,7 @@ def _validate_grid_source( source_result, ) ) - elif (entity_energy_price := flow_from.get("entity_energy_price")) is not None: + elif (entity_energy_price := source.get("entity_energy_price")) is not None: validate_calls.append( functools.partial( _async_validate_price_entity, @@ -442,27 +459,27 @@ def _validate_grid_source( ) if ( - flow_from.get("entity_energy_price") is not None - or flow_from.get("number_energy_price") is not None + source.get("entity_energy_price") is not None + or source.get("number_energy_price") is not None ): validate_calls.append( functools.partial( _async_validate_auto_generated_cost_entity, hass, - flow_from["stat_energy_from"], + stat_energy_from, source_result, ) ) - flow_to: data.FlowToGridSourceType - for flow_to in source["flow_to"]: - wanted_statistics_metadata.add(flow_to["stat_energy_to"]) + # Validate export meter (optional) + if stat_energy_to: + wanted_statistics_metadata.add(stat_energy_to) validate_calls.append( functools.partial( _async_validate_usage_stat, hass, statistics_metadata, - flow_to["stat_energy_to"], + stat_energy_to, ENERGY_USAGE_DEVICE_CLASSES, ENERGY_USAGE_UNITS, ENERGY_UNIT_ERROR, @@ -470,7 +487,8 @@ def _validate_grid_source( ) ) - if (stat_compensation := flow_to.get("stat_compensation")) is not None: + # Validate export compensation tracking + if (stat_compensation := source.get("stat_compensation")) is not None: wanted_statistics_metadata.add(stat_compensation) validate_calls.append( functools.partial( @@ -481,12 +499,14 @@ def _validate_grid_source( source_result, ) ) - elif (entity_energy_price := flow_to.get("entity_energy_price")) is not None: + elif ( + entity_price_export := source.get("entity_energy_price_export") + ) is not None: validate_calls.append( functools.partial( _async_validate_price_entity, hass, - entity_energy_price, + entity_price_export, source_result, ENERGY_PRICE_UNITS, ENERGY_PRICE_UNIT_ERROR, @@ -494,26 +514,27 @@ def _validate_grid_source( ) if ( - flow_to.get("entity_energy_price") is not None - or flow_to.get("number_energy_price") is not None + source.get("entity_energy_price_export") is not None + or source.get("number_energy_price_export") is not None ): validate_calls.append( functools.partial( _async_validate_auto_generated_cost_entity, hass, - flow_to["stat_energy_to"], + stat_energy_to, source_result, ) ) - for power_stat in source.get("power", []): - wanted_statistics_metadata.add(power_stat["stat_rate"]) + # Validate power sensor (optional) + if stat_rate: + wanted_statistics_metadata.add(stat_rate) validate_calls.append( functools.partial( _async_validate_power_stat, hass, statistics_metadata, - power_stat["stat_rate"], + stat_rate, POWER_USAGE_DEVICE_CLASSES, POWER_USAGE_UNITS, POWER_UNIT_ERROR, @@ -581,6 +602,21 @@ def _validate_gas_source( ) ) + if stat_rate := source.get("stat_rate"): + wanted_statistics_metadata.add(stat_rate) + validate_calls.append( + functools.partial( + _async_validate_power_stat, + hass, + statistics_metadata, + stat_rate, + VOLUME_FLOW_RATE_DEVICE_CLASSES, + VOLUME_FLOW_RATE_UNITS, + VOLUME_FLOW_RATE_UNIT_ERROR, + source_result, + ) + ) + def _validate_water_source( hass: HomeAssistant, @@ -641,6 +677,21 @@ def _validate_water_source( ) ) + if stat_rate := source.get("stat_rate"): + wanted_statistics_metadata.add(stat_rate) + validate_calls.append( + functools.partial( + _async_validate_power_stat, + hass, + statistics_metadata, + stat_rate, + VOLUME_FLOW_RATE_DEVICE_CLASSES, + VOLUME_FLOW_RATE_UNITS, + VOLUME_FLOW_RATE_UNIT_ERROR, + source_result, + ) + ) + async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: """Validate the energy configuration.""" diff --git a/homeassistant/components/enocean/__init__.py b/homeassistant/components/enocean/__init__.py index 7c55f47a979171..a7ee93ac5e2ba9 100644 --- a/homeassistant/components/enocean/__init__.py +++ b/homeassistant/components/enocean/__init__.py @@ -1,17 +1,22 @@ """Support for EnOcean devices.""" +from enocean_async import Gateway import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_DEVICE from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN -from .dongle import EnOceanDongle +from .const import DOMAIN, SIGNAL_RECEIVE_MESSAGE, SIGNAL_SEND_MESSAGE -type EnOceanConfigEntry = ConfigEntry[EnOceanDongle] +type EnOceanConfigEntry = ConfigEntry[Gateway] CONFIG_SCHEMA = vol.Schema( {DOMAIN: vol.Schema({vol.Required(CONF_DEVICE): cv.string})}, extra=vol.ALLOW_EXTRA @@ -25,7 +30,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True if hass.config_entries.async_entries(DOMAIN): - # We can only have one dongle. If there is already one in the config, + # We can only have one gateway. If there is already one in the config, # there is no need to import the yaml based config. return True @@ -41,20 +46,31 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry( hass: HomeAssistant, config_entry: EnOceanConfigEntry ) -> bool: - """Set up an EnOcean dongle for the given entry.""" - usb_dongle = EnOceanDongle(hass, config_entry.data[CONF_DEVICE]) - await usb_dongle.async_setup() - config_entry.runtime_data = usb_dongle + """Set up an EnOcean gateway for the given entry.""" + gateway = Gateway(port=config_entry.data[CONF_DEVICE]) + + gateway.add_erp1_received_callback( + lambda packet: async_dispatcher_send(hass, SIGNAL_RECEIVE_MESSAGE, packet) + ) + + try: + await gateway.start() + except ConnectionError as err: + gateway.stop() + raise ConfigEntryNotReady(f"Failed to start EnOcean gateway: {err}") from err + config_entry.runtime_data = gateway + + config_entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_SEND_MESSAGE, gateway.send_esp3_packet) + ) return True async def async_unload_entry( hass: HomeAssistant, config_entry: EnOceanConfigEntry ) -> bool: - """Unload EnOcean config entry.""" - - enocean_dongle = config_entry.runtime_data - enocean_dongle.unload() + """Unload EnOcean config entry: stop the gateway.""" + config_entry.runtime_data.stop() return True diff --git a/homeassistant/components/enocean/binary_sensor.py b/homeassistant/components/enocean/binary_sensor.py index 26039036ca03a8..5c5dad08f76032 100644 --- a/homeassistant/components/enocean/binary_sensor.py +++ b/homeassistant/components/enocean/binary_sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations -from enocean.utils import combine_hex +from enocean_async import ERP1Telegram import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -17,7 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .entity import EnOceanEntity +from .entity import EnOceanEntity, combine_hex DEFAULT_NAME = "EnOcean binary sensor" DEPENDENCIES = ["enocean"] @@ -68,29 +68,25 @@ def __init__( self._attr_unique_id = f"{combine_hex(dev_id)}-{device_class}" self._attr_name = dev_name - def value_changed(self, packet): + def value_changed(self, telegram: ERP1Telegram) -> None: """Fire an event with the data that have changed. This method is called when there is an incoming packet associated with this platform. - - Example packet data: - - 2nd button pressed - ['0xf6', '0x10', '0x00', '0x2d', '0xcf', '0x45', '0x30'] - - button released - ['0xf6', '0x00', '0x00', '0x2d', '0xcf', '0x45', '0x20'] """ + if not self.address: + return # Energy Bow pushed = None - if packet.data[6] == 0x30: + if telegram.status == 0x30: pushed = 1 - elif packet.data[6] == 0x20: + elif telegram.status == 0x20: pushed = 0 self.schedule_update_ha_state() - action = packet.data[1] + action = telegram.telegram_data[0] if action == 0x70: self.which = 0 self.onoff = 0 @@ -112,7 +108,7 @@ def value_changed(self, packet): self.hass.bus.fire( EVENT_BUTTON_PRESSED, { - "id": self.dev_id, + "id": self.address.to_bytelist(), "pushed": pushed, "which": self.which, "onoff": self.onoff, diff --git a/homeassistant/components/enocean/config_flow.py b/homeassistant/components/enocean/config_flow.py index 0f7b1126425965..1b42b2da471a0e 100644 --- a/homeassistant/components/enocean/config_flow.py +++ b/homeassistant/components/enocean/config_flow.py @@ -1,20 +1,27 @@ """Config flows for the EnOcean integration.""" +import glob from typing import Any +from enocean_async import Gateway import voluptuous as vol +from homeassistant.components import usb +from homeassistant.components.usb import ( + human_readable_device_name, + usb_unique_id_from_service_info, +) from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_DEVICE +from homeassistant.const import ATTR_MANUFACTURER, CONF_DEVICE, CONF_NAME from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( SelectSelector, SelectSelectorConfig, SelectSelectorMode, ) +from homeassistant.helpers.service_info.usb import UsbServiceInfo -from . import dongle -from .const import DOMAIN, ERROR_INVALID_DONGLE_PATH, LOGGER +from .const import DOMAIN, ERROR_INVALID_DONGLE_PATH, LOGGER, MANUFACTURER MANUAL_SCHEMA = vol.Schema( { @@ -23,6 +30,24 @@ ) +def _detect_usb_dongle() -> list[str]: + """Return a list of candidate paths for USB EnOcean dongles. + + This method is currently a bit simplistic, it may need to be + improved to support more configurations and OS. + """ + globs_to_test = [ + "/dev/tty*FTOA2PV*", + "/dev/serial/by-id/*EnOcean*", + "/dev/tty.usbserial-*", + ] + found_paths = [] + for current_glob in globs_to_test: + found_paths.extend(glob.glob(current_glob)) + + return found_paths + + class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN): """Handle the enOcean config flows.""" @@ -31,8 +56,48 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the EnOcean config flow.""" - self.dongle_path = None - self.discovery_info = None + self.data: dict[str, Any] = {} + + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: + """Handle usb discovery.""" + unique_id = usb_unique_id_from_service_info(discovery_info) + + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured( + updates={CONF_DEVICE: discovery_info.device} + ) + + discovery_info.device = await self.hass.async_add_executor_job( + usb.get_serial_by_id, discovery_info.device + ) + + self.data[CONF_DEVICE] = discovery_info.device + self.context["title_placeholders"] = { + CONF_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 user_input is not None: + return await self.async_step_manual({CONF_DEVICE: self.data[CONF_DEVICE]}) + self._set_confirm_only() + return self.async_show_form( + step_id="usb_confirm", + description_placeholders={ + ATTR_MANUFACTURER: MANUFACTURER, + CONF_DEVICE: self.data.get(CONF_DEVICE, ""), + }, + ) async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: """Import a yaml configuration.""" @@ -61,7 +126,7 @@ async def async_step_detect( return await self.async_step_manual() return await self.async_step_manual(user_input) - devices = await self.hass.async_add_executor_job(dongle.detect) + devices = await self.hass.async_add_executor_job(_detect_usb_dongle) if len(devices) == 0: return await self.async_step_manual() devices.append(self.MANUAL_PATH_VALUE) @@ -100,8 +165,18 @@ async def async_step_manual( async def validate_enocean_conf(self, user_input) -> bool: """Return True if the user_input contains a valid dongle path.""" dongle_path = user_input[CONF_DEVICE] - return await self.hass.async_add_executor_job(dongle.validate_path, dongle_path) + try: + # Starting the gateway will raise an exception if it can't connect + gateway = Gateway(port=dongle_path) + await gateway.start() + except ConnectionError as exception: + LOGGER.warning("Dongle path %s is invalid: %s", dongle_path, str(exception)) + return False + finally: + gateway.stop() + + return True def create_enocean_entry(self, user_input): """Create an entry for the provided configuration.""" - return self.async_create_entry(title="EnOcean", data=user_input) + return self.async_create_entry(title=MANUFACTURER, data=user_input) diff --git a/homeassistant/components/enocean/const.py b/homeassistant/components/enocean/const.py index 8c4692830741e5..d08fad3c870e0d 100644 --- a/homeassistant/components/enocean/const.py +++ b/homeassistant/components/enocean/const.py @@ -6,6 +6,8 @@ DOMAIN = "enocean" +MANUFACTURER = "EnOcean" + ERROR_INVALID_DONGLE_PATH = "invalid_dongle_path" SIGNAL_RECEIVE_MESSAGE = "enocean.receive_message" diff --git a/homeassistant/components/enocean/dongle.py b/homeassistant/components/enocean/dongle.py deleted file mode 100644 index 43214b12064d1a..00000000000000 --- a/homeassistant/components/enocean/dongle.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Representation of an EnOcean dongle.""" - -import glob -import logging -from os.path import basename, normpath - -from enocean.communicators import SerialCommunicator -from enocean.protocol.packet import RadioPacket -import serial - -from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send - -from .const import SIGNAL_RECEIVE_MESSAGE, SIGNAL_SEND_MESSAGE - -_LOGGER = logging.getLogger(__name__) - - -class EnOceanDongle: - """Representation of an EnOcean dongle. - - The dongle is responsible for receiving the EnOcean frames, - creating devices if needed, and dispatching messages to platforms. - """ - - def __init__(self, hass, serial_path): - """Initialize the EnOcean dongle.""" - - self._communicator = SerialCommunicator( - port=serial_path, callback=self.callback - ) - self.serial_path = serial_path - self.identifier = basename(normpath(serial_path)) - self.hass = hass - self.dispatcher_disconnect_handle = None - - async def async_setup(self): - """Finish the setup of the bridge and supported platforms.""" - self._communicator.start() - self.dispatcher_disconnect_handle = async_dispatcher_connect( - self.hass, SIGNAL_SEND_MESSAGE, self._send_message_callback - ) - - def unload(self): - """Disconnect callbacks established at init time.""" - if self.dispatcher_disconnect_handle: - self.dispatcher_disconnect_handle() - self.dispatcher_disconnect_handle = None - - def _send_message_callback(self, command): - """Send a command through the EnOcean dongle.""" - self._communicator.send(command) - - def callback(self, packet): - """Handle EnOcean device's callback. - - This is the callback function called by python-enocean whenever there - is an incoming packet. - """ - - if isinstance(packet, RadioPacket): - _LOGGER.debug("Received radio packet: %s", packet) - dispatcher_send(self.hass, SIGNAL_RECEIVE_MESSAGE, packet) - - -def detect(): - """Return a list of candidate paths for USB EnOcean dongles. - - This method is currently a bit simplistic, it may need to be - improved to support more configurations and OS. - """ - globs_to_test = ["/dev/tty*FTOA2PV*", "/dev/serial/by-id/*EnOcean*"] - found_paths = [] - for current_glob in globs_to_test: - found_paths.extend(glob.glob(current_glob)) - - return found_paths - - -def validate_path(path: str): - """Return True if the provided path points to a valid serial port, False otherwise.""" - try: - # Creating the serial communicator will raise an exception - # if it cannot connect - SerialCommunicator(port=path) - except serial.SerialException as exception: - _LOGGER.warning("Dongle path %s is invalid: %s", path, str(exception)) - return False - return True diff --git a/homeassistant/components/enocean/entity.py b/homeassistant/components/enocean/entity.py index b2d73e65443df2..caf3016758a32b 100644 --- a/homeassistant/components/enocean/entity.py +++ b/homeassistant/components/enocean/entity.py @@ -1,12 +1,23 @@ """Representation of an EnOcean device.""" -from enocean.protocol.packet import Packet -from enocean.utils import combine_hex +from enocean_async import EURID, Address, BaseAddress, ERP1Telegram, SenderAddress +from enocean_async.esp3.packet import ESP3Packet, ESP3PacketType from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity import Entity -from .const import SIGNAL_RECEIVE_MESSAGE, SIGNAL_SEND_MESSAGE +from .const import LOGGER, SIGNAL_RECEIVE_MESSAGE, SIGNAL_SEND_MESSAGE + + +def combine_hex(dev_id: list[int]) -> int: + """Combine list of integer values to one big integer. + + This function replaces the previously used function from the enocean library and is considered tech debt that will have to be replaced. + """ + value = 0 + for byte in dev_id: + value = (value << 8) | (byte & 0xFF) + return value class EnOceanEntity(Entity): @@ -14,7 +25,16 @@ class EnOceanEntity(Entity): def __init__(self, dev_id: list[int]) -> None: """Initialize the device.""" - self.dev_id = dev_id + self.address: SenderAddress | None = None + + try: + address = Address.from_bytelist(dev_id) + if address.is_eurid(): + self.address = EURID.from_number(address.to_number()) + elif address.is_base_address(): + self.address = BaseAddress.from_number(address.to_number()) + except ValueError: + self.address = None async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -24,17 +44,25 @@ async def async_added_to_hass(self) -> None: ) ) - def _message_received_callback(self, packet): + def _message_received_callback(self, telegram: ERP1Telegram) -> None: """Handle incoming packets.""" + if not self.address: + return - if packet.sender_int == combine_hex(self.dev_id): - self.value_changed(packet) + if telegram.sender == self.address: + self.value_changed(telegram) - def value_changed(self, packet): + def value_changed(self, telegram: ERP1Telegram) -> None: """Update the internal state of the device when a packet arrives.""" - def send_command(self, data, optional, packet_type): - """Send a command via the EnOcean dongle.""" - - packet = Packet(packet_type, data=data, optional=optional) - dispatcher_send(self.hass, SIGNAL_SEND_MESSAGE, packet) + def send_command( + self, data: list[int], optional: list[int], packet_type: ESP3PacketType + ) -> None: + """Send a command via the EnOcean dongle, if data and optional are valid bytes; otherwise, ignore.""" + try: + packet = ESP3Packet(packet_type, data=bytes(data), optional=bytes(optional)) + dispatcher_send(self.hass, SIGNAL_SEND_MESSAGE, packet) + except ValueError as err: + LOGGER.warning( + "Failed to send command: invalid data or optional bytes: %s", err + ) diff --git a/homeassistant/components/enocean/light.py b/homeassistant/components/enocean/light.py index 6586714c1b61bc..645667c8412aaa 100644 --- a/homeassistant/components/enocean/light.py +++ b/homeassistant/components/enocean/light.py @@ -5,7 +5,8 @@ import math from typing import Any -from enocean.utils import combine_hex +from enocean_async import ERP1Telegram +from enocean_async.esp3.packet import ESP3PacketType import voluptuous as vol from homeassistant.components.light import ( @@ -20,7 +21,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .entity import EnOceanEntity +from .entity import EnOceanEntity, combine_hex CONF_SENDER_ID = "sender_id" @@ -75,7 +76,8 @@ def turn_on(self, **kwargs: Any) -> None: command = [0xA5, 0x02, bval, 0x01, 0x09] command.extend(self._sender_id) command.extend([0x00]) - self.send_command(command, [], 0x01) + packet_type = ESP3PacketType(0x01) + self.send_command(command, [], packet_type) self._attr_is_on = True def turn_off(self, **kwargs: Any) -> None: @@ -83,17 +85,18 @@ def turn_off(self, **kwargs: Any) -> None: command = [0xA5, 0x02, 0x00, 0x01, 0x09] command.extend(self._sender_id) command.extend([0x00]) - self.send_command(command, [], 0x01) + packet_type = ESP3PacketType(0x01) + self.send_command(command, [], packet_type) self._attr_is_on = False - def value_changed(self, packet): + def value_changed(self, telegram: ERP1Telegram) -> None: """Update the internal state of this device. Dimmer devices like Eltako FUD61 send telegram in different RORGs. We only care about the 4BS (0xA5). """ - if packet.data[0] == 0xA5 and packet.data[1] == 0x02: - val = packet.data[2] + if telegram.rorg == 0xA5 and telegram.telegram_data[0] == 0x02: + val = telegram.telegram_data[1] self._attr_brightness = math.floor(val / 100.0 * 256.0) self._attr_is_on = bool(val != 0) self.schedule_update_ha_state() diff --git a/homeassistant/components/enocean/manifest.json b/homeassistant/components/enocean/manifest.json index b7eba277b7717b..deafe8a9ac93c9 100644 --- a/homeassistant/components/enocean/manifest.json +++ b/homeassistant/components/enocean/manifest.json @@ -3,10 +3,19 @@ "name": "EnOcean", "codeowners": [], "config_flow": true, + "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/enocean", - "integration_type": "device", + "integration_type": "hub", "iot_class": "local_push", - "loggers": ["enocean"], - "requirements": ["enocean==0.50"], - "single_config_entry": true + "loggers": ["enocean_async"], + "requirements": ["enocean-async==0.4.2"], + "single_config_entry": true, + "usb": [ + { + "description": "*usb 300*", + "manufacturer": "*enocean*", + "pid": "6001", + "vid": "0403" + } + ] } diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py index 2a4b9364d813da..b852690d05b503 100644 --- a/homeassistant/components/enocean/sensor.py +++ b/homeassistant/components/enocean/sensor.py @@ -5,7 +5,7 @@ from collections.abc import Callable from dataclasses import dataclass -from enocean.utils import combine_hex +from enocean_async import EEP, EEP_SPECIFICATIONS, EEPHandler, EEPMessage, ERP1Telegram import voluptuous as vol from homeassistant.components.sensor import ( @@ -30,7 +30,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .entity import EnOceanEntity +from .entity import EnOceanEntity, combine_hex CONF_MAX_TEMP = "max_temp" CONF_MIN_TEMP = "min_temp" @@ -166,7 +166,7 @@ async def async_added_to_hass(self) -> None: if (sensor_data := await self.async_get_last_sensor_data()) is not None: self._attr_native_value = sensor_data.native_value - def value_changed(self, packet): + def value_changed(self, telegram: ERP1Telegram) -> None: """Update the internal state of the sensor.""" @@ -177,15 +177,19 @@ class EnOceanPowerSensor(EnOceanSensor): - A5-12-01 (Automated Meter Reading, Electricity) """ - def value_changed(self, packet): + def value_changed(self, telegram: ERP1Telegram) -> None: """Update the internal state of the sensor.""" - if packet.rorg != 0xA5: + if telegram.rorg != 0xA5: return - packet.parse_eep(0x12, 0x01) - if packet.parsed["DT"]["raw_value"] == 1: + + if (eep := EEP_SPECIFICATIONS.get(EEP(0xA5, 0x12, 0x01))) is None: + return + msg: EEPMessage = EEPHandler(eep).decode(telegram) + + if "DT" in msg.values and msg.values["DT"].raw == 1: # this packet reports the current value - raw_val = packet.parsed["MR"]["raw_value"] - divisor = packet.parsed["DIV"]["raw_value"] + raw_val = msg.values["MR"].raw + divisor = msg.values["DIV"].raw self._attr_native_value = raw_val / (10**divisor) self.schedule_update_ha_state() @@ -226,13 +230,13 @@ def __init__( self.range_from = range_from self.range_to = range_to - def value_changed(self, packet): + def value_changed(self, telegram: ERP1Telegram) -> None: """Update the internal state of the sensor.""" - if packet.data[0] != 0xA5: + if telegram.rorg != 0xA5: return temp_scale = self._scale_max - self._scale_min temp_range = self.range_to - self.range_from - raw_val = packet.data[3] + raw_val = telegram.telegram_data[2] temperature = temp_scale / temp_range * (raw_val - self.range_from) temperature += self._scale_min self._attr_native_value = round(temperature, 1) @@ -248,11 +252,11 @@ class EnOceanHumiditySensor(EnOceanSensor): - A5-10-10 to A5-10-14 (Room Operating Panels) """ - def value_changed(self, packet): + def value_changed(self, telegram: ERP1Telegram) -> None: """Update the internal state of the sensor.""" - if packet.rorg != 0xA5: + if telegram.rorg != 0xA5: return - humidity = packet.data[2] * 100 / 250 + humidity = telegram.telegram_data[1] * 100 / 250 self._attr_native_value = round(humidity, 1) self.schedule_update_ha_state() @@ -264,9 +268,9 @@ class EnOceanWindowHandle(EnOceanSensor): - F6-10-00 (Mechanical handle / Hoppe AG) """ - def value_changed(self, packet): + def value_changed(self, telegram: ERP1Telegram) -> None: """Update the internal state of the sensor.""" - action = (packet.data[1] & 0x70) >> 4 + action = (telegram.telegram_data[0] & 0x70) >> 4 if action == 0x07: self._attr_native_value = STATE_CLOSED diff --git a/homeassistant/components/enocean/strings.json b/homeassistant/components/enocean/strings.json index a8ce2e839331a3..3cb3a270aa73bc 100644 --- a/homeassistant/components/enocean/strings.json +++ b/homeassistant/components/enocean/strings.json @@ -25,6 +25,9 @@ "device": "[%key:component::enocean::config::step::detect::data_description::device%]" }, "description": "Enter the path to your EnOcean USB dongle." + }, + "usb_confirm": { + "description": "{manufacturer} USB dongle detected at {device}. Do you want to set up this device?" } } }, diff --git a/homeassistant/components/enocean/switch.py b/homeassistant/components/enocean/switch.py index 0259a60982f2de..676ca99eb7e803 100644 --- a/homeassistant/components/enocean/switch.py +++ b/homeassistant/components/enocean/switch.py @@ -4,7 +4,8 @@ from typing import Any -from enocean.utils import combine_hex +from enocean_async import EEP, EEP_SPECIFICATIONS, EEPHandler, EEPMessage, ERP1Telegram +from enocean_async.esp3.packet import ESP3PacketType import voluptuous as vol from homeassistant.components.switch import ( @@ -18,7 +19,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN, LOGGER -from .entity import EnOceanEntity +from .entity import EnOceanEntity, combine_hex CONF_CHANNEL = "channel" DEFAULT_NAME = "EnOcean Switch" @@ -86,52 +87,68 @@ def __init__(self, dev_id: list[int], dev_name: str, channel: int) -> None: """Initialize the EnOcean switch device.""" super().__init__(dev_id) self._light = None - self.channel = channel + self.channel: int = channel self._attr_unique_id = generate_unique_id(dev_id, channel) self._attr_name = dev_name def turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" + if not self.address: + return + optional = [0x03] - optional.extend(self.dev_id) + optional.extend(self.address.to_bytelist()) optional.extend([0xFF, 0x00]) self.send_command( data=[0xD2, 0x01, self.channel & 0xFF, 0x64, 0x00, 0x00, 0x00, 0x00, 0x00], optional=optional, - packet_type=0x01, + packet_type=ESP3PacketType(0x01), ) self._attr_is_on = True def turn_off(self, **kwargs: Any) -> None: """Turn off the switch.""" + if not self.address: + return optional = [0x03] - optional.extend(self.dev_id) + optional.extend(self.address.to_bytelist()) optional.extend([0xFF, 0x00]) self.send_command( data=[0xD2, 0x01, self.channel & 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], optional=optional, - packet_type=0x01, + packet_type=ESP3PacketType(0x01), ) self._attr_is_on = False - def value_changed(self, packet): + def value_changed(self, telegram: ERP1Telegram) -> None: """Update the internal state of the switch.""" - if packet.data[0] == 0xA5: - # power meter telegram, turn on if > 10 watts - packet.parse_eep(0x12, 0x01) - if packet.parsed["DT"]["raw_value"] == 1: - raw_val = packet.parsed["MR"]["raw_value"] - divisor = packet.parsed["DIV"]["raw_value"] + if telegram.rorg == 0xA5: + # power meter telegram, turn on if > 1 watts + if (eep := EEP_SPECIFICATIONS.get(EEP(0xA5, 0x12, 0x01))) is None: + LOGGER.warning("EEP A5-12-01 cannot be decoded") + return + + msg: EEPMessage = EEPHandler(eep).decode(telegram) + + if "DT" in msg.values and msg.values["DT"].raw == 1: + # this packet reports the current value + raw_val = msg.values["MR"].raw + divisor = msg.values["DIV"].raw watts = raw_val / (10**divisor) if watts > 1: self._attr_is_on = True self.schedule_update_ha_state() - elif packet.data[0] == 0xD2: + + elif telegram.rorg == 0xD2: # actuator status telegram - packet.parse_eep(0x01, 0x01) - if packet.parsed["CMD"]["raw_value"] == 4: - channel = packet.parsed["IO"]["raw_value"] - output = packet.parsed["OV"]["raw_value"] + if (eep := EEP_SPECIFICATIONS.get(EEP(0xD2, 0x01, 0x01))) is None: + LOGGER.warning("EEP D2-01-01 cannot be decoded") + return + + msg = EEPHandler(eep).decode(telegram) + if msg.values["CMD"].raw == 4: + channel = msg.values["I/O"].raw + output = msg.values["OV"].raw if channel == self.channel: self._attr_is_on = output > 0 self.schedule_update_ha_state() diff --git a/homeassistant/components/enphase_envoy/diagnostics.py b/homeassistant/components/enphase_envoy/diagnostics.py index e3d86e7fa233c6..1517d2a1d67e70 100644 --- a/homeassistant/components/enphase_envoy/diagnostics.py +++ b/homeassistant/components/enphase_envoy/diagnostics.py @@ -11,7 +11,7 @@ from pyenphase.envoy import Envoy from pyenphase.exceptions import EnvoyError -from homeassistant.components.diagnostics import async_redact_data +from homeassistant.components.diagnostics import async_redact_data, entity_entry_as_dict from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, @@ -111,8 +111,7 @@ async def async_get_config_entry_diagnostics( if state := hass.states.get(entity.entity_id): state_dict = dict(state.as_dict()) state_dict.pop("context", None) - entity_dict = asdict(entity) - entity_dict.pop("_cache", None) + entity_dict = entity_entry_as_dict(entity) entities.append({"entity": entity_dict, "state": state_dict}) device_dict = asdict(device) device_dict.pop("_cache", None) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 273e7df81ad0c0..d3180b1f983152 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["pyenphase"], "quality_scale": "platinum", - "requirements": ["pyenphase==2.4.5"], + "requirements": ["pyenphase==2.4.6"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 7ea8ae68fdb255..bc82b85eb50fed 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -405,8 +405,13 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): ) for cttype, key in ( (CtType.NET_CONSUMPTION, "lifetime_net_consumption"), - # Production CT energy_delivered is not used + (CtType.PRODUCTION, "production_ct_energy_delivered"), (CtType.STORAGE, "lifetime_battery_discharged"), + (CtType.TOTAL_CONSUMPTION, "total_consumption_ct_energy_delivered"), + (CtType.BACKFEED, "backfeed_ct_energy_delivered"), + (CtType.LOAD, "load_ct_energy_delivered"), + (CtType.EVSE, "evse_ct_energy_delivered"), + (CtType.PV3P, "pv3p_ct_energy_delivered"), ) ] + [ @@ -423,8 +428,13 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): ) for cttype, key in ( (CtType.NET_CONSUMPTION, "lifetime_net_production"), - # Production CT energy_received is not used + (CtType.PRODUCTION, "production_ct_energy_received"), (CtType.STORAGE, "lifetime_battery_charged"), + (CtType.TOTAL_CONSUMPTION, "total_consumption_ct_energy_received"), + (CtType.BACKFEED, "backfeed_ct_energy_received"), + (CtType.LOAD, "load_ct_energy_received"), + (CtType.EVSE, "evse_ct_energy_received"), + (CtType.PV3P, "pv3p_ct_energy_received"), ) ] + [ @@ -441,8 +451,13 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): ) for cttype, key in ( (CtType.NET_CONSUMPTION, "net_consumption"), - # Production CT active_power is not used + (CtType.PRODUCTION, "production_ct_power"), (CtType.STORAGE, "battery_discharge"), + (CtType.TOTAL_CONSUMPTION, "total_consumption_ct_power"), + (CtType.BACKFEED, "backfeed_ct_power"), + (CtType.LOAD, "load_ct_power"), + (CtType.EVSE, "evse_ct_power"), + (CtType.PV3P, "pv3p_ct_power"), ) ] + [ @@ -461,6 +476,11 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): (CtType.NET_CONSUMPTION, "frequency", "net_ct_frequency"), (CtType.PRODUCTION, "production_ct_frequency", ""), (CtType.STORAGE, "storage_ct_frequency", ""), + (CtType.TOTAL_CONSUMPTION, "total_consumption_ct_frequency", ""), + (CtType.BACKFEED, "backfeed_ct_frequency", ""), + (CtType.LOAD, "load_ct_frequency", ""), + (CtType.EVSE, "evse_ct_frequency", ""), + (CtType.PV3P, "pv3p_ct_frequency", ""), ) ] + [ @@ -480,6 +500,11 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): (CtType.NET_CONSUMPTION, "voltage", "net_ct_voltage"), (CtType.PRODUCTION, "production_ct_voltage", ""), (CtType.STORAGE, "storage_voltage", "storage_ct_voltage"), + (CtType.TOTAL_CONSUMPTION, "total_consumption_ct_voltage", ""), + (CtType.BACKFEED, "backfeed_ct_voltage", ""), + (CtType.LOAD, "load_ct_voltage", ""), + (CtType.EVSE, "evse_ct_voltage", ""), + (CtType.PV3P, "pv3p_ct_voltage", ""), ) ] + [ @@ -499,6 +524,11 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): (CtType.NET_CONSUMPTION, "net_ct_current"), (CtType.PRODUCTION, "production_ct_current"), (CtType.STORAGE, "storage_ct_current"), + (CtType.TOTAL_CONSUMPTION, "total_consumption_ct_current"), + (CtType.BACKFEED, "backfeed_ct_current"), + (CtType.LOAD, "load_ct_current"), + (CtType.EVSE, "evse_ct_current"), + (CtType.PV3P, "pv3p_ct_current"), ) ] + [ @@ -516,6 +546,11 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): (CtType.NET_CONSUMPTION, "net_ct_powerfactor"), (CtType.PRODUCTION, "production_ct_powerfactor"), (CtType.STORAGE, "storage_ct_powerfactor"), + (CtType.TOTAL_CONSUMPTION, "total_consumption_ct_powerfactor"), + (CtType.BACKFEED, "backfeed_ct_powerfactor"), + (CtType.LOAD, "load_ct_powerfactor"), + (CtType.EVSE, "evse_ct_powerfactor"), + (CtType.PV3P, "pv3p_ct_powerfactor"), ) ] + [ @@ -537,6 +572,11 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): ), (CtType.PRODUCTION, "production_ct_metering_status", ""), (CtType.STORAGE, "storage_ct_metering_status", ""), + (CtType.TOTAL_CONSUMPTION, "total_consumption_ct_metering_status", ""), + (CtType.BACKFEED, "backfeed_ct_metering_status", ""), + (CtType.LOAD, "load_ct_metering_status", ""), + (CtType.EVSE, "evse_ct_metering_status", ""), + (CtType.PV3P, "pv3p_ct_metering_status", ""), ) ] + [ @@ -557,6 +597,11 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): ), (CtType.PRODUCTION, "production_ct_status_flags", ""), (CtType.STORAGE, "storage_ct_status_flags", ""), + (CtType.TOTAL_CONSUMPTION, "total_consumption_ct_status_flags", ""), + (CtType.BACKFEED, "backfeed_ct_status_flags", ""), + (CtType.LOAD, "load_ct_status_flags", ""), + (CtType.EVSE, "evse_ct_status_flags", ""), + (CtType.PV3P, "pv3p_ct_status_flags", ""), ) ] ) diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 2866f504d4504a..12ce059967b867 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -160,6 +160,60 @@ "available_energy": { "name": "Available battery energy" }, + "backfeed_ct_current": { + "name": "Backfeed CT current" + }, + "backfeed_ct_current_phase": { + "name": "Backfeed CT current {phase_name}" + }, + "backfeed_ct_energy_delivered": { + "name": "Backfeed CT energy delivered" + }, + "backfeed_ct_energy_delivered_phase": { + "name": "Backfeed CT energy delivered {phase_name}" + }, + "backfeed_ct_energy_received": { + "name": "Backfeed CT energy received" + }, + "backfeed_ct_energy_received_phase": { + "name": "Backfeed CT energy received {phase_name}" + }, + "backfeed_ct_frequency": { + "name": "Frequency backfeed CT" + }, + "backfeed_ct_frequency_phase": { + "name": "Frequency backfeed CT {phase_name}" + }, + "backfeed_ct_metering_status": { + "name": "Metering status backfeed CT" + }, + "backfeed_ct_metering_status_phase": { + "name": "Metering status backfeed CT {phase_name}" + }, + "backfeed_ct_power": { + "name": "Backfeed CT power" + }, + "backfeed_ct_power_phase": { + "name": "Backfeed CT power {phase_name}" + }, + "backfeed_ct_powerfactor": { + "name": "Power factor backfeed CT" + }, + "backfeed_ct_powerfactor_phase": { + "name": "Power factor backfeed CT {phase_name}" + }, + "backfeed_ct_status_flags": { + "name": "Meter status flags active backfeed CT" + }, + "backfeed_ct_status_flags_phase": { + "name": "Meter status flags active backfeed CT {phase_name}" + }, + "backfeed_ct_voltage": { + "name": "Voltage backfeed CT" + }, + "backfeed_ct_voltage_phase": { + "name": "Voltage backfeed CT {phase_name}" + }, "balanced_net_consumption": { "name": "Balanced net power consumption" }, @@ -211,6 +265,60 @@ "energy_today": { "name": "[%key:component::enphase_envoy::entity::sensor::daily_production::name%]" }, + "evse_ct_current": { + "name": "EVSE CT current" + }, + "evse_ct_current_phase": { + "name": "EVSE CT current {phase_name}" + }, + "evse_ct_energy_delivered": { + "name": "EVSE CT energy delivered" + }, + "evse_ct_energy_delivered_phase": { + "name": "EVSE CT energy delivered {phase_name}" + }, + "evse_ct_energy_received": { + "name": "EVSE CT energy received" + }, + "evse_ct_energy_received_phase": { + "name": "EVSE CT energy received {phase_name}" + }, + "evse_ct_frequency": { + "name": "Frequency EVSE CT" + }, + "evse_ct_frequency_phase": { + "name": "Frequency EVSE CT {phase_name}" + }, + "evse_ct_metering_status": { + "name": "Metering status EVSE CT" + }, + "evse_ct_metering_status_phase": { + "name": "Metering status EVSE CT {phase_name}" + }, + "evse_ct_power": { + "name": "EVSE CT power" + }, + "evse_ct_power_phase": { + "name": "EVSE CT power {phase_name}" + }, + "evse_ct_powerfactor": { + "name": "Power factor EVSE CT" + }, + "evse_ct_powerfactor_phase": { + "name": "Power factor EVSE CT {phase_name}" + }, + "evse_ct_status_flags": { + "name": "Meter status flags active EVSE CT" + }, + "evse_ct_status_flags_phase": { + "name": "Meter status flags active EVSE CT {phase_name}" + }, + "evse_ct_voltage": { + "name": "Voltage EVSE CT" + }, + "evse_ct_voltage_phase": { + "name": "Voltage EVSE CT {phase_name}" + }, "grid_status": { "name": "[%key:component::enphase_envoy::entity::binary_sensor::grid_status::name%]", "state": { @@ -270,6 +378,60 @@ "lifetime_production_phase": { "name": "Lifetime energy production {phase_name}" }, + "load_ct_current": { + "name": "Load CT current" + }, + "load_ct_current_phase": { + "name": "Load CT current {phase_name}" + }, + "load_ct_energy_delivered": { + "name": "Load CT energy delivered" + }, + "load_ct_energy_delivered_phase": { + "name": "Load CT energy delivered {phase_name}" + }, + "load_ct_energy_received": { + "name": "Load CT energy received" + }, + "load_ct_energy_received_phase": { + "name": "Load CT energy received {phase_name}" + }, + "load_ct_frequency": { + "name": "Frequency load CT" + }, + "load_ct_frequency_phase": { + "name": "Frequency load CT {phase_name}" + }, + "load_ct_metering_status": { + "name": "Metering status load CT" + }, + "load_ct_metering_status_phase": { + "name": "Metering status load CT {phase_name}" + }, + "load_ct_power": { + "name": "Load CT power" + }, + "load_ct_power_phase": { + "name": "Load CT power {phase_name}" + }, + "load_ct_powerfactor": { + "name": "Power factor load CT" + }, + "load_ct_powerfactor_phase": { + "name": "Power factor load CT {phase_name}" + }, + "load_ct_status_flags": { + "name": "Meter status flags active load CT" + }, + "load_ct_status_flags_phase": { + "name": "Meter status flags active load CT {phase_name}" + }, + "load_ct_voltage": { + "name": "Voltage load CT" + }, + "load_ct_voltage_phase": { + "name": "Voltage load CT {phase_name}" + }, "max_capacity": { "name": "Battery capacity" }, @@ -331,6 +493,18 @@ "production_ct_current_phase": { "name": "Production CT current {phase_name}" }, + "production_ct_energy_delivered": { + "name": "Production CT energy delivered" + }, + "production_ct_energy_delivered_phase": { + "name": "Production CT energy delivered {phase_name}" + }, + "production_ct_energy_received": { + "name": "Production CT energy received" + }, + "production_ct_energy_received_phase": { + "name": "Production CT energy received {phase_name}" + }, "production_ct_frequency": { "name": "Frequency production CT" }, @@ -343,6 +517,12 @@ "production_ct_metering_status_phase": { "name": "Metering status production CT {phase_name}" }, + "production_ct_power": { + "name": "Production CT power" + }, + "production_ct_power_phase": { + "name": "Production CT power {phase_name}" + }, "production_ct_powerfactor": { "name": "Power factor production CT" }, @@ -361,6 +541,60 @@ "production_ct_voltage_phase": { "name": "Voltage production CT {phase_name}" }, + "pv3p_ct_current": { + "name": "PV3P CT current" + }, + "pv3p_ct_current_phase": { + "name": "PV3P CT current {phase_name}" + }, + "pv3p_ct_energy_delivered": { + "name": "PV3P CT energy delivered" + }, + "pv3p_ct_energy_delivered_phase": { + "name": "PV3P CT energy delivered {phase_name}" + }, + "pv3p_ct_energy_received": { + "name": "PV3P CT energy received" + }, + "pv3p_ct_energy_received_phase": { + "name": "PV3P CT energy received {phase_name}" + }, + "pv3p_ct_frequency": { + "name": "Frequency PV3P CT" + }, + "pv3p_ct_frequency_phase": { + "name": "Frequency PV3P CT {phase_name}" + }, + "pv3p_ct_metering_status": { + "name": "Metering status PV3P CT" + }, + "pv3p_ct_metering_status_phase": { + "name": "Metering status PV3P CT {phase_name}" + }, + "pv3p_ct_power": { + "name": "PV3P CT power" + }, + "pv3p_ct_power_phase": { + "name": "PV3P CT power {phase_name}" + }, + "pv3p_ct_powerfactor": { + "name": "Power factor PV3P CT" + }, + "pv3p_ct_powerfactor_phase": { + "name": "Power factor PV3P CT {phase_name}" + }, + "pv3p_ct_status_flags": { + "name": "Meter status flags active PV3P CT" + }, + "pv3p_ct_status_flags_phase": { + "name": "Meter status flags active PV3P CT {phase_name}" + }, + "pv3p_ct_voltage": { + "name": "Voltage PV3P CT" + }, + "pv3p_ct_voltage_phase": { + "name": "Voltage PV3P CT {phase_name}" + }, "reserve_energy": { "name": "Reserve battery energy" }, @@ -414,6 +648,60 @@ }, "storage_ct_voltage_phase": { "name": "Voltage storage CT {phase_name}" + }, + "total_consumption_ct_current": { + "name": "Total consumption CT current" + }, + "total_consumption_ct_current_phase": { + "name": "Total consumption CT current {phase_name}" + }, + "total_consumption_ct_energy_delivered": { + "name": "Total consumption CT energy delivered" + }, + "total_consumption_ct_energy_delivered_phase": { + "name": "Total consumption CT energy delivered {phase_name}" + }, + "total_consumption_ct_energy_received": { + "name": "Total consumption CT energy received" + }, + "total_consumption_ct_energy_received_phase": { + "name": "Total consumption CT energy received {phase_name}" + }, + "total_consumption_ct_frequency": { + "name": "Frequency total consumption CT" + }, + "total_consumption_ct_frequency_phase": { + "name": "Frequency total consumption CT {phase_name}" + }, + "total_consumption_ct_metering_status": { + "name": "Metering status total consumption CT" + }, + "total_consumption_ct_metering_status_phase": { + "name": "Metering status total consumption CT {phase_name}" + }, + "total_consumption_ct_power": { + "name": "Total consumption CT power" + }, + "total_consumption_ct_power_phase": { + "name": "Total consumption CT power {phase_name}" + }, + "total_consumption_ct_powerfactor": { + "name": "Power factor total consumption CT" + }, + "total_consumption_ct_powerfactor_phase": { + "name": "Power factor total consumption CT {phase_name}" + }, + "total_consumption_ct_status_flags": { + "name": "Meter status flags active total consumption CT" + }, + "total_consumption_ct_status_flags_phase": { + "name": "Meter status flags active total consumption CT {phase_name}" + }, + "total_consumption_ct_voltage": { + "name": "Voltage total consumption CT" + }, + "total_consumption_ct_voltage_phase": { + "name": "Voltage total consumption CT {phase_name}" } }, "switch": { diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index c5f1d71f36ed71..63c7067792c491 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["env_canada"], - "requirements": ["env-canada==0.12.4"] + "requirements": ["env-canada==0.13.2"] } diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index d27da132a35704..75d60ef16de92f 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -322,7 +322,7 @@ class ECAlertSensorEntity(ECBaseSensorEntity[ECWeather]): """Environment Canada sensor for alerts.""" @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the extra state attributes.""" value = self.entity_description.value_fn(self._ec_data) if not value: diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index a5acb224bd0bd0..c7d04e4c03d88e 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -123,7 +123,7 @@ def __init__(self, coordinator: ECDataUpdateCoordinator[ECWeather]) -> None: self._attr_device_info = coordinator.device_info @property - def native_temperature(self): + def native_temperature(self) -> float | None: """Return the temperature.""" if ( temperature := self.ec_data.conditions.get("temperature", {}).get("value") @@ -138,42 +138,42 @@ def native_temperature(self): return None @property - def humidity(self): + def humidity(self) -> float | None: """Return the humidity.""" if self.ec_data.conditions.get("humidity", {}).get("value"): return float(self.ec_data.conditions["humidity"]["value"]) return None @property - def native_wind_speed(self): + def native_wind_speed(self) -> float | None: """Return the wind speed.""" if self.ec_data.conditions.get("wind_speed", {}).get("value"): return float(self.ec_data.conditions["wind_speed"]["value"]) return None @property - def wind_bearing(self): + def wind_bearing(self) -> float | None: """Return the wind bearing.""" if self.ec_data.conditions.get("wind_bearing", {}).get("value"): return float(self.ec_data.conditions["wind_bearing"]["value"]) return None @property - def native_pressure(self): + def native_pressure(self) -> float | None: """Return the pressure.""" if self.ec_data.conditions.get("pressure", {}).get("value"): return float(self.ec_data.conditions["pressure"]["value"]) return None @property - def native_visibility(self): + def native_visibility(self) -> float | None: """Return the visibility.""" if self.ec_data.conditions.get("visibility", {}).get("value"): return float(self.ec_data.conditions["visibility"]["value"]) return None @property - def condition(self): + def condition(self) -> str | None: """Return the weather condition.""" icon_code = None @@ -186,7 +186,7 @@ def condition(self): if icon_code: return icon_code_to_condition(int(icon_code)) - return "" + return None @callback def _async_forecast_daily(self) -> list[Forecast] | None: @@ -261,7 +261,7 @@ def get_day_forecast( return forecast_array -def icon_code_to_condition(icon_code): +def icon_code_to_condition(icon_code: int) -> str | None: """Return the condition corresponding to an icon code.""" for condition, codes in ICON_CONDITION_MAP.items(): if icon_code in codes: diff --git a/homeassistant/components/envisalink/binary_sensor.py b/homeassistant/components/envisalink/binary_sensor.py index aa91731216fcfa..792fae3947be94 100644 --- a/homeassistant/components/envisalink/binary_sensor.py +++ b/homeassistant/components/envisalink/binary_sensor.py @@ -116,7 +116,7 @@ def extra_state_attributes(self) -> dict[str, Any]: return attr @property - def is_on(self): + def is_on(self) -> bool: """Return true if sensor is on.""" return self._info["status"]["open"] diff --git a/homeassistant/components/envisalink/sensor.py b/homeassistant/components/envisalink/sensor.py index d9b9ccab1640c4..4c445a76a8505f 100644 --- a/homeassistant/components/envisalink/sensor.py +++ b/homeassistant/components/envisalink/sensor.py @@ -89,7 +89,7 @@ def native_value(self): return self._info["status"]["alpha"] @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return self._info["status"] diff --git a/homeassistant/components/envisalink/switch.py b/homeassistant/components/envisalink/switch.py index 81ecf8d8789762..3082057f9f3bb5 100644 --- a/homeassistant/components/envisalink/switch.py +++ b/homeassistant/components/envisalink/switch.py @@ -77,7 +77,7 @@ async def async_added_to_hass(self) -> None: ) @property - def is_on(self): + def is_on(self) -> bool: """Return the boolean response if the zone is bypassed.""" return self._info["bypassed"] diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index 9b3d954d22185c..945b0714cd4712 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -524,14 +524,10 @@ async def handle_pipeline_start( self._active_pipeline_index = 0 maybe_pipeline_index = 0 - while True: - if not (ww_entity_id := self.get_wake_word_entity(maybe_pipeline_index)): - break - - if not (ww_state := self.hass.states.get(ww_entity_id)): - continue - - if ww_state.state == wake_word_phrase: + while ww_entity_id := self.get_wake_word_entity(maybe_pipeline_index): + if ( + ww_state := self.hass.states.get(ww_entity_id) + ) and ww_state.state == wake_word_phrase: # First match self._active_pipeline_index = maybe_pipeline_index break diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index 7d7ef60a6297ce..d37fda3396e844 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -189,6 +189,7 @@ async def platform_async_setup_entry( info_type: type[_InfoT], entity_type: type[_EntityT], state_type: type[_StateT], + info_filter: Callable[[_InfoT], bool] | None = None, ) -> None: """Set up an esphome platform. @@ -208,10 +209,22 @@ async def platform_async_setup_entry( entity_type, state_type, ) + + if info_filter is not None: + + def on_filtered_update(infos: list[EntityInfo]) -> None: + on_static_info_update( + [info for info in infos if info_filter(cast(_InfoT, info))] + ) + + info_callback = on_filtered_update + else: + info_callback = on_static_info_update + entry_data.cleanup_callbacks.append( entry_data.async_register_static_info_callback( info_type, - on_static_info_update, + info_callback, ) ) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index aaabec66146737..46059407294f8d 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -29,6 +29,7 @@ Event, EventInfo, FanInfo, + InfraredInfo, LightInfo, LockInfo, MediaPlayerInfo, @@ -85,6 +86,7 @@ DateTimeInfo: Platform.DATETIME, EventInfo: Platform.EVENT, FanInfo: Platform.FAN, + InfraredInfo: Platform.INFRARED, LightInfo: Platform.LIGHT, LockInfo: Platform.LOCK, MediaPlayerInfo: Platform.MEDIA_PLAYER, @@ -300,16 +302,23 @@ async def async_update_static_infos( needed_platforms.add(Platform.BINARY_SENSOR) needed_platforms.add(Platform.SELECT) - needed_platforms.update(INFO_TYPE_TO_PLATFORM[type(info)] for info in infos) - await self._ensure_platforms_loaded(hass, entry, needed_platforms) - # Make a dict of the EntityInfo by type and send # them to the listeners for each specific EntityInfo type + info_types_to_platform = INFO_TYPE_TO_PLATFORM infos_by_type: defaultdict[type[EntityInfo], list[EntityInfo]] = defaultdict( list ) for info in infos: - infos_by_type[type(info)].append(info) + info_type = type(info) + if platform := info_types_to_platform.get(info_type): + needed_platforms.add(platform) + infos_by_type[info_type].append(info) + else: + _LOGGER.warning( + "Entity type %s is not supported in this version of Home Assistant", + info_type, + ) + await self._ensure_platforms_loaded(hass, entry, needed_platforms) for type_, callbacks in self.entity_info_callbacks.items(): # If all entities for a type are removed, we diff --git a/homeassistant/components/esphome/infrared.py b/homeassistant/components/esphome/infrared.py new file mode 100644 index 00000000000000..580831f4aec9ab --- /dev/null +++ b/homeassistant/components/esphome/infrared.py @@ -0,0 +1,59 @@ +"""Infrared platform for ESPHome.""" + +from __future__ import annotations + +from functools import partial +import logging + +from aioesphomeapi import EntityState, InfraredCapability, InfraredInfo + +from homeassistant.components.infrared import InfraredCommand, InfraredEntity +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 + + +class EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState], InfraredEntity): + """ESPHome infrared entity using native API.""" + + @callback + def _on_device_update(self) -> None: + """Call when device updates or entry data changes.""" + super()._on_device_update() + if self._entry_data.available: + # Infrared entities should go available as soon as the device comes online + self.async_write_ha_state() + + @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) + ] + _LOGGER.debug("Sending command: %s", timings) + + self._client.infrared_rf_transmit_raw_timings( + self._static_info.key, + carrier_frequency=command.modulation, + timings=timings, + device_id=self._static_info.device_id, + ) + + +async_setup_entry = partial( + platform_async_setup_entry, + info_type=InfraredInfo, + entity_type=EsphomeInfraredEntity, + state_type=EntityState, + info_filter=lambda info: bool(info.capabilities & InfraredCapability.TRANSMITTER), +) diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index 91719301a488fb..8fc52d2477d0b7 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -160,6 +160,23 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): _native_supported_color_modes: tuple[ESPHomeColorMode, ...] _supports_color_mode = False + def _color_temp_to_cold_warm(self, color_temp_mired: float) -> tuple[float, float]: + """Convert a color temperature in mireds to cold/warm white fractions. + + Returns (cold_white, warm_white) normalized so the brighter channel + is 1.0. + """ + static_info = self._static_info + min_mireds = static_info.min_mireds + max_mireds = static_info.max_mireds + if max_mireds <= min_mireds: + return 1.0, 1.0 + color_temp_clamped = min(max(color_temp_mired, min_mireds), max_mireds) + ww_frac = (color_temp_clamped - min_mireds) / (max_mireds - min_mireds) + cw_frac = 1 - ww_frac + max_frac = max(cw_frac, ww_frac) + return cw_frac / max_frac, ww_frac / max_frac + @property @esphome_state_property def is_on(self) -> bool: @@ -241,12 +258,19 @@ async def async_turn_on(self, **kwargs: Any) -> None: if (color_temp_k := kwargs.get(ATTR_COLOR_TEMP_KELVIN)) is not None: # Do not use kelvin_to_mired here to prevent precision loss - data["color_temperature"] = 1000000.0 / color_temp_k + color_temp_mired = 1_000_000.0 / color_temp_k if color_temp_modes := _filter_color_modes( color_modes, LightColorCapability.COLOR_TEMPERATURE ): + data["color_temperature"] = color_temp_mired color_modes = color_temp_modes else: + # Convert color temperature to explicit cold/warm white + # values to avoid ESPHome applying brightness to both + # master brightness and white channels (b² effect). + data["cold_white"], data["warm_white"] = self._color_temp_to_cold_warm( + color_temp_mired + ) color_modes = _filter_color_modes( color_modes, LightColorCapability.COLD_WARM_WHITE ) @@ -345,19 +369,13 @@ def rgbww_color(self) -> tuple[int, int, int, int, int]: self._native_supported_color_modes, LightColorCapability.COLD_WARM_WHITE ): # Try to reverse white + color temp to cwww - static_info = self._static_info - min_ct = static_info.min_mireds - max_ct = static_info.max_mireds - color_temp = min(max(state.color_temperature, min_ct), max_ct) white = state.white - - ww_frac = (color_temp - min_ct) / (max_ct - min_ct) - cw_frac = 1 - ww_frac + cw, ww = self._color_temp_to_cold_warm(state.color_temperature) return ( *rgb, - round(white * cw_frac / max(cw_frac, ww_frac) * 255), - round(white * ww_frac / max(cw_frac, ww_frac) * 255), + round(white * cw * 255), + round(white * ww * 255), ) return ( *rgb, diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index e1e0181235c1e2..8b6e9753d386ea 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,9 +17,9 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==44.0.0", + "aioesphomeapi==44.5.2", "esphome-dashboard-api==1.3.0", - "bleak-esphome==3.6.0" + "bleak-esphome==3.7.1" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py index db16ad4010528a..df5a923c8b3973 100644 --- a/homeassistant/components/esphome/select.py +++ b/homeassistant/components/esphome/select.py @@ -123,19 +123,13 @@ class EsphomeAssistSatelliteWakeWordSelect( def __init__(self, entry_data: RuntimeEntryData, index: int = 0) -> None: """Initialize a wake word selector.""" - if index < 1: - # Keep compatibility - key_suffix = "" - placeholder = "" - else: - key_suffix = f"_{index + 1}" - placeholder = f" {index + 1}" - - self.entity_description = replace( - self.entity_description, - key=f"wake_word{key_suffix}", - translation_placeholders={"index": placeholder}, - ) + if index >= 1: + self.entity_description = replace( + self.entity_description, + key=f"wake_word_{index + 1}", + translation_key="wake_word_n", + translation_placeholders={"index": str(index + 1)}, + ) EsphomeAssistEntity.__init__(self, entry_data) diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 9abcec6df96ec6..2ef93c2a820e58 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -107,6 +107,12 @@ "preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]" } }, + "pipeline_n": { + "name": "[%key:component::assist_pipeline::entity::select::pipeline_n::name%]", + "state": { + "preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]" + } + }, "vad_sensitivity": { "name": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::name%]", "state": { @@ -116,11 +122,18 @@ } }, "wake_word": { - "name": "Wake word{index}", + "name": "Wake word", "state": { "no_wake_word": "No wake word", "okay_nabu": "Okay Nabu" } + }, + "wake_word_n": { + "name": "Wake word {index}", + "state": { + "no_wake_word": "[%key:component::esphome::entity::select::wake_word::state::no_wake_word%]", + "okay_nabu": "[%key:component::esphome::entity::select::wake_word::state::okay_nabu%]" + } } } }, diff --git a/homeassistant/components/esphome/water_heater.py b/homeassistant/components/esphome/water_heater.py index f294f38b24c09d..2f80d018150994 100644 --- a/homeassistant/components/esphome/water_heater.py +++ b/homeassistant/components/esphome/water_heater.py @@ -5,7 +5,13 @@ from functools import partial from typing import Any -from aioesphomeapi import EntityInfo, WaterHeaterInfo, WaterHeaterMode, WaterHeaterState +from aioesphomeapi import ( + EntityInfo, + WaterHeaterFeature, + WaterHeaterInfo, + WaterHeaterMode, + WaterHeaterState, +) from homeassistant.components.water_heater import ( WaterHeaterEntity, @@ -54,6 +60,7 @@ def _on_static_info_update(self, static_info: EntityInfo) -> None: static_info = self._static_info self._attr_min_temp = static_info.min_temperature self._attr_max_temp = static_info.max_temperature + self._attr_target_temperature_step = static_info.target_temperature_step features = WaterHeaterEntityFeature.TARGET_TEMPERATURE if static_info.supported_modes: features |= WaterHeaterEntityFeature.OPERATION_MODE @@ -63,6 +70,8 @@ def _on_static_info_update(self, static_info: EntityInfo) -> None: ] else: self._attr_operation_list = None + if static_info.supported_features & WaterHeaterFeature.SUPPORTS_ON_OFF: + features |= WaterHeaterEntityFeature.ON_OFF self._attr_supported_features = features @property @@ -101,6 +110,24 @@ async def async_set_operation_mode(self, operation_mode: str) -> None: device_id=self._static_info.device_id, ) + @convert_api_error_ha_error + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the water heater on.""" + self._client.water_heater_command( + key=self._key, + on=True, + device_id=self._static_info.device_id, + ) + + @convert_api_error_ha_error + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the water heater off.""" + self._client.water_heater_command( + key=self._key, + on=False, + device_id=self._static_info.device_id, + ) + async_setup_entry = partial( platform_async_setup_entry, diff --git a/homeassistant/components/eufy/light.py b/homeassistant/components/eufy/light.py index dcce52612eec1c..48ba97c01df5b3 100644 --- a/homeassistant/components/eufy/light.py +++ b/homeassistant/components/eufy/light.py @@ -46,12 +46,12 @@ def __init__(self, device): self._temp = None self._brightness = None self._hs = None - self._state = None - self._name = device["name"] - self._address = device["address"] - self._code = device["code"] + self._attr_name = device["name"] self._type = device["type"] - self._bulb = lakeside.bulb(self._address, self._code, self._type) + self._bulb = lakeside.bulb( + (device_address := device["address"]), device["code"], self._type + ) + self._attr_unique_id = device_address self._colormode = False if self._type == "T1011": self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} @@ -72,25 +72,10 @@ def update(self) -> None: self._hs = color_util.color_RGB_to_hs(*self._bulb.colors) else: self._colormode = False - self._state = self._bulb.power - - @property - def unique_id(self): - """Return the ID of this light.""" - return self._address - - @property - def name(self): - """Return the name of the device if any.""" - return self._name - - @property - def is_on(self): - """Return true if device is on.""" - return self._state + self._attr_is_on = self._bulb.power @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" return int(self._brightness * 255 / 100) @@ -103,7 +88,7 @@ def color_temp_kelvin(self) -> int: ) @property - def hs_color(self): + def hs_color(self) -> tuple[float, float] | None: """Return the color of this light.""" return self._hs diff --git a/homeassistant/components/eufy/switch.py b/homeassistant/components/eufy/switch.py index 58bcc6ceb21d90..2f3e5931e615b6 100644 --- a/homeassistant/components/eufy/switch.py +++ b/homeassistant/components/eufy/switch.py @@ -30,33 +30,17 @@ class EufyHomeSwitch(SwitchEntity): def __init__(self, device): """Initialize the light.""" - self._state = None - self._name = device["name"] - self._address = device["address"] - self._code = device["code"] - self._type = device["type"] - self._switch = lakeside.switch(self._address, self._code, self._type) + self._attr_name = device["name"] + self._attr_unique_id = device["address"] + self._switch = lakeside.switch( + device["address"], device["code"], device["type"] + ) self._switch.connect() def update(self) -> None: """Synchronise state from the switch.""" self._switch.update() - self._state = self._switch.power - - @property - def unique_id(self): - """Return the ID of this light.""" - return self._address - - @property - def name(self): - """Return the name of the device if any.""" - return self._name - - @property - def is_on(self): - """Return true if device is on.""" - return self._state + self._attr_is_on = self._switch.power def turn_on(self, **kwargs: Any) -> None: """Turn the specified switch on.""" diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index a94801520e245b..36a51edc3bc8d8 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -36,19 +36,12 @@ UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError 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_DURATION_UNTIL, - ATTR_PERIOD, - ATTR_SETPOINT, - EVOHOME_DATA, - EvoService, -) +from .const import ATTR_DURATION, ATTR_PERIOD, DOMAIN, EVOHOME_DATA, EvoService from .coordinator import EvoDataUpdateCoordinator from .entity import EvoChild, EvoEntity @@ -139,6 +132,24 @@ class EvoClimateEntity(EvoEntity, ClimateEntity): _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] _attr_temperature_unit = UnitOfTemperature.CELSIUS + async def async_clear_zone_override(self) -> None: + """Clear the zone override; only supported by zones.""" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="zone_only_service", + translation_placeholders={"service": EvoService.CLEAR_ZONE_OVERRIDE}, + ) + + async def async_set_zone_override( + self, setpoint: float, duration: timedelta | None = None + ) -> None: + """Set the zone override; only supported by zones.""" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="zone_only_service", + translation_placeholders={"service": EvoService.SET_ZONE_OVERRIDE}, + ) + class EvoZone(EvoChild, EvoClimateEntity): """Base for any evohome-compatible heating zone.""" @@ -177,22 +188,22 @@ def __init__( | ClimateEntityFeature.TURN_ON ) - async def async_zone_svc_request(self, service: str, data: dict[str, Any]) -> None: - """Process a service request (setpoint override) for a zone.""" - if service == EvoService.RESET_ZONE_OVERRIDE: - await self.coordinator.call_client_api(self._evo_device.reset()) - return + async def async_clear_zone_override(self) -> None: + """Clear the zone's override, if any.""" + await self.coordinator.call_client_api(self._evo_device.reset()) - # otherwise it is EvoService.SET_ZONE_OVERRIDE - temperature = max(min(data[ATTR_SETPOINT], self.max_temp), self.min_temp) + async def async_set_zone_override( + self, setpoint: float, duration: timedelta | None = None + ) -> None: + """Set the zone's override (mode/setpoint).""" + temperature = max(min(setpoint, self.max_temp), self.min_temp) - if ATTR_DURATION_UNTIL in data: - duration: timedelta = data[ATTR_DURATION_UNTIL] + 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() + data[ATTR_DURATION_UNTIL] + until = dt_util.now() + duration else: until = None # indefinitely @@ -352,10 +363,12 @@ async def async_tcs_svc_request(self, service: str, data: dict[str, Any]) -> Non Data validation is not required, it will have been done upstream. """ - if service == EvoService.SET_SYSTEM_MODE: - mode = data[ATTR_MODE] - else: # otherwise it is EvoService.RESET_SYSTEM - mode = EvoSystemMode.AUTO_WITH_RESET + + if service == EvoService.RESET_SYSTEM: + await self.coordinator.call_client_api(self._evo_device.reset()) + return + + mode = data[ATTR_MODE] # otherwise it is EvoService.SET_SYSTEM_MODE if ATTR_PERIOD in data: until = dt_util.start_of_local_day() diff --git a/homeassistant/components/evohome/const.py b/homeassistant/components/evohome/const.py index d8aff1bef8fcdc..f601ebbfecbd17 100644 --- a/homeassistant/components/evohome/const.py +++ b/homeassistant/components/evohome/const.py @@ -28,7 +28,6 @@ ATTR_DURATION: Final = "duration" # number of minutes, <24h ATTR_SETPOINT: Final = "setpoint" -ATTR_DURATION_UNTIL: Final = "duration" @unique @@ -39,4 +38,4 @@ class EvoService(StrEnum): SET_SYSTEM_MODE = "set_system_mode" RESET_SYSTEM = "reset_system" SET_ZONE_OVERRIDE = "set_zone_override" - RESET_ZONE_OVERRIDE = "clear_zone_override" + CLEAR_ZONE_OVERRIDE = "clear_zone_override" diff --git a/homeassistant/components/evohome/entity.py b/homeassistant/components/evohome/entity.py index fc13868ef355c2..0879fe739bc265 100644 --- a/homeassistant/components/evohome/entity.py +++ b/homeassistant/components/evohome/entity.py @@ -12,7 +12,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, EvoService +from .const import DOMAIN from .coordinator import EvoDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -47,22 +47,12 @@ async def process_signal(self, payload: dict | None = None) -> None: raise NotImplementedError if payload["unique_id"] != self._attr_unique_id: return - if payload["service"] in ( - EvoService.SET_ZONE_OVERRIDE, - EvoService.RESET_ZONE_OVERRIDE, - ): - await self.async_zone_svc_request(payload["service"], payload["data"]) - 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 - async def async_zone_svc_request(self, service: str, data: dict[str, Any]) -> None: - """Process a service request (setpoint override) for a zone.""" - raise NotImplementedError - @property def extra_state_attributes(self) -> Mapping[str, Any]: """Return the evohome-specific state attributes.""" diff --git a/homeassistant/components/evohome/services.py b/homeassistant/components/evohome/services.py index d37c64ace93ddb..e93ccce1df2145 100644 --- a/homeassistant/components/evohome/services.py +++ b/homeassistant/components/evohome/services.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import timedelta -from typing import Final +from typing import Any, Final from evohomeasync2.const import SZ_CAN_BE_TEMPORARY, SZ_SYSTEM_MODE, SZ_TIMING_MODE from evohomeasync2.schemas.const import ( @@ -13,40 +13,50 @@ ) import voluptuous as vol -from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.const import ATTR_MODE from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers import config_validation as cv, service from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.service import verify_domain_control -from .const import ( - ATTR_DURATION, - ATTR_DURATION_UNTIL, - ATTR_PERIOD, - ATTR_SETPOINT, - DOMAIN, - EvoService, -) +from .const import ATTR_DURATION, ATTR_PERIOD, ATTR_SETPOINT, DOMAIN, EvoService from .coordinator import EvoDataUpdateCoordinator # system mode schemas are built dynamically when the services are registered # because supported modes can vary for edge-case systems -RESET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema( - {vol.Required(ATTR_ENTITY_ID): cv.entity_id} -) -SET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema( - { - vol.Required(ATTR_ENTITY_ID): cv.entity_id, - vol.Required(ATTR_SETPOINT): vol.All( - vol.Coerce(float), vol.Range(min=4.0, max=35.0) - ), - vol.Optional(ATTR_DURATION_UNTIL): vol.All( - cv.time_period, - vol.Range(min=timedelta(days=0), max=timedelta(days=1)), - ), - } -) +# Zone service schemas (registered as entity services) +SET_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = { + vol.Required(ATTR_SETPOINT): vol.All( + vol.Coerce(float), vol.Range(min=4.0, max=35.0) + ), + 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.""" + + service.async_register_platform_entity_service( + hass, + DOMAIN, + EvoService.CLEAR_ZONE_OVERRIDE, + entity_domain=CLIMATE_DOMAIN, + schema=None, + func="async_clear_zone_override", + ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + EvoService.SET_ZONE_OVERRIDE, + entity_domain=CLIMATE_DOMAIN, + schema=SET_ZONE_OVERRIDE_SCHEMA, + func="async_set_zone_override", + ) @callback @@ -58,8 +68,6 @@ def setup_service_functions( 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. - - It appears that all TCC-compatible systems support the same three zones modes. """ @verify_domain_control(DOMAIN) @@ -70,7 +78,6 @@ async def force_refresh(call: ServiceCall) -> None: @verify_domain_control(DOMAIN) async def set_system_mode(call: ServiceCall) -> None: """Set the system mode.""" - assert coordinator.tcs is not None # mypy payload = { "unique_id": coordinator.tcs.id, @@ -79,43 +86,14 @@ async def set_system_mode(call: ServiceCall) -> None: } async_dispatcher_send(hass, DOMAIN, payload) - @verify_domain_control(DOMAIN) - async def set_zone_override(call: ServiceCall) -> None: - """Set the zone override (setpoint).""" - entity_id = call.data[ATTR_ENTITY_ID] - - registry = er.async_get(hass) - registry_entry = registry.async_get(entity_id) - - if registry_entry is None or registry_entry.platform != DOMAIN: - raise ValueError(f"'{entity_id}' is not a known {DOMAIN} entity") - - if registry_entry.domain != "climate": - raise ValueError(f"'{entity_id}' is not an {DOMAIN} controller/zone") - - payload = { - "unique_id": registry_entry.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) - # Not all systems support "AutoWithReset": register this handler only if required - if any( - m[SZ_SYSTEM_MODE] - for m in modes - if m[SZ_SYSTEM_MODE] == EvoSystemMode.AUTO_WITH_RESET - ): - hass.services.async_register(DOMAIN, EvoService.RESET_SYSTEM, set_system_mode) - system_mode_schemas = [] modes = [m for m in modes if m[SZ_SYSTEM_MODE] != EvoSystemMode.AUTO_WITH_RESET] @@ -163,16 +141,4 @@ async def set_zone_override(call: ServiceCall) -> None: schema=vol.Schema(vol.Any(*system_mode_schemas)), ) - # The zone modes are consistent across all systems and use the same schema - hass.services.async_register( - DOMAIN, - EvoService.RESET_ZONE_OVERRIDE, - set_zone_override, - schema=RESET_ZONE_OVERRIDE_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - EvoService.SET_ZONE_OVERRIDE, - set_zone_override, - schema=SET_ZONE_OVERRIDE_SCHEMA, - ) + _register_zone_entity_services(hass) diff --git a/homeassistant/components/evohome/services.yaml b/homeassistant/components/evohome/services.yaml index 60dcf37ebb0ebb..cbf39f9c215707 100644 --- a/homeassistant/components/evohome/services.yaml +++ b/homeassistant/components/evohome/services.yaml @@ -28,14 +28,11 @@ reset_system: refresh_system: set_zone_override: + target: + entity: + integration: evohome + domain: climate fields: - entity_id: - required: true - example: climate.bathroom - selector: - entity: - integration: evohome - domain: climate setpoint: required: true selector: @@ -49,10 +46,7 @@ set_zone_override: object: clear_zone_override: - fields: - entity_id: - required: true - selector: - entity: - integration: evohome - domain: climate + target: + entity: + integration: evohome + domain: climate diff --git a/homeassistant/components/evohome/strings.json b/homeassistant/components/evohome/strings.json index 4f69eef4193baa..6e39b24f8a67e4 100644 --- a/homeassistant/components/evohome/strings.json +++ b/homeassistant/components/evohome/strings.json @@ -1,13 +1,12 @@ { + "exceptions": { + "zone_only_service": { + "message": "Only zones support the `{service}` action" + } + }, "services": { "clear_zone_override": { "description": "Sets a zone to follow its schedule.", - "fields": { - "entity_id": { - "description": "[%key:component::evohome::services::set_zone_override::fields::entity_id::description%]", - "name": "[%key:component::evohome::services::set_zone_override::fields::entity_id::name%]" - } - }, "name": "Clear zone override" }, "refresh_system": { @@ -43,10 +42,6 @@ "description": "The zone will revert to its schedule after this time. If 0 the change is until the next scheduled setpoint.", "name": "Duration" }, - "entity_id": { - "description": "The entity ID of the Evohome zone.", - "name": "Entity" - }, "setpoint": { "description": "The temperature to be used instead of the scheduled setpoint.", "name": "Setpoint" diff --git a/homeassistant/components/ezviz/icons.json b/homeassistant/components/ezviz/icons.json index 6f34593b41d1ff..2ad43493bb7607 100644 --- a/homeassistant/components/ezviz/icons.json +++ b/homeassistant/components/ezviz/icons.json @@ -23,6 +23,23 @@ "alarm_sound_mode": { "default": "mdi:alarm" } + }, + "sensor": { + "alarm_sound_mode": { + "default": "mdi:alarm" + }, + "last_alarm_type_code": { + "default": "mdi:alarm" + }, + "last_alarm_type_name": { + "default": "mdi:alarm" + }, + "local_ip": { + "default": "mdi:ip" + }, + "wan_ip": { + "default": "mdi:ip" + } } }, "services": { diff --git a/homeassistant/components/facebook/notify.py b/homeassistant/components/facebook/notify.py index 674da78ead2dd6..ba998e79e3adf5 100644 --- a/homeassistant/components/facebook/notify.py +++ b/homeassistant/components/facebook/notify.py @@ -77,7 +77,7 @@ def send_message(self, message: str = "", **kwargs: Any) -> None: "recipient": recipient, "message": body_message, "messaging_type": "MESSAGE_TAG", - "tag": "ACCOUNT_UPDATE", + "tag": "HUMAN_AGENT", } resp = requests.post( BASE_URL, diff --git a/homeassistant/components/fail2ban/sensor.py b/homeassistant/components/fail2ban/sensor.py index e4b6a1e90ee1cf..aa29f28244bd45 100644 --- a/homeassistant/components/fail2ban/sensor.py +++ b/homeassistant/components/fail2ban/sensor.py @@ -6,6 +6,7 @@ import logging import os import re +from typing import Any import voluptuous as vol @@ -76,7 +77,7 @@ def name(self): return self._name @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the fail2ban sensor.""" return self.ban_dict diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index bde62b234dc7de..d56cd113e76e63 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -275,8 +275,11 @@ def _read_devices(self) -> None: # otherwise add the first visible device in the group # which is a hack, but solves a problem with FGT having # hidden compatibility devices before the real device - if last_climate_parent != device.parent_fibaro_id or ( - device.has_endpoint_id and last_endpoint != device.endpoint_id + # Second hack is for quickapps which have parent id 0 and no children + if ( + last_climate_parent != device.parent_fibaro_id + or (device.has_endpoint_id and last_endpoint != device.endpoint_id) + or device.parent_fibaro_id == 0 ): _LOGGER.debug("Handle separately") self.fibaro_devices[platform].append(device) diff --git a/homeassistant/components/fido/sensor.py b/homeassistant/components/fido/sensor.py index 86e81a596d7346..cbce2efd7c5e3a 100644 --- a/homeassistant/components/fido/sensor.py +++ b/homeassistant/components/fido/sensor.py @@ -8,6 +8,7 @@ from datetime import timedelta import logging +from typing import Any from pyfido import FidoClient from pyfido.client import PyFidoError @@ -226,7 +227,7 @@ def __init__( self._attr_name = f"{name} {number} {description.name}" @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the sensor.""" return {"number": self._number} diff --git a/homeassistant/components/fish_audio/config_flow.py b/homeassistant/components/fish_audio/config_flow.py index b6ce0a00436f55..17ab9d21505a90 100644 --- a/homeassistant/components/fish_audio/config_flow.py +++ b/homeassistant/components/fish_audio/config_flow.py @@ -111,7 +111,7 @@ def get_model_selection_schema( ), vol.Required( CONF_BACKEND, - default=options.get(CONF_BACKEND, "s1"), + default=options.get(CONF_BACKEND, "s2-pro"), ): SelectSelector( SelectSelectorConfig( options=[ diff --git a/homeassistant/components/fish_audio/const.py b/homeassistant/components/fish_audio/const.py index bbff953e3bf856..93f4c244d8ce18 100644 --- a/homeassistant/components/fish_audio/const.py +++ b/homeassistant/components/fish_audio/const.py @@ -31,7 +31,7 @@ ] -BACKEND_MODELS = ["s1", "speech-1.5", "speech-1.6"] +BACKEND_MODELS = ["s2-pro", "s1", "speech-1.5", "speech-1.6"] SORT_BY_OPTIONS = ["task_count", "score", "created_at"] LATENCY_OPTIONS = ["normal", "balanced"] diff --git a/homeassistant/components/fitbit/api.py b/homeassistant/components/fitbit/api.py index 7a273e3ba18055..b04310e57063c4 100644 --- a/homeassistant/components/fitbit/api.py +++ b/homeassistant/components/fitbit/api.py @@ -72,7 +72,7 @@ async def _async_get_fitbit_web_api(self) -> ApiClient: configuration = Configuration() configuration.pool_manager = async_get_clientsession(self._hass) configuration.access_token = token[CONF_ACCESS_TOKEN] - return ApiClient(configuration) + return await self._hass.async_add_executor_job(ApiClient, configuration) async def async_get_user_profile(self) -> FitbitProfile: """Return the user profile from the API.""" diff --git a/homeassistant/components/flexit_bacnet/strings.json b/homeassistant/components/flexit_bacnet/strings.json index 1a3d4e1211df91..8b9ff3c0199acf 100644 --- a/homeassistant/components/flexit_bacnet/strings.json +++ b/homeassistant/components/flexit_bacnet/strings.json @@ -154,7 +154,7 @@ }, "issues": { "deprecated_fireplace_switch": { - "description": "The fireplace mode switch entity `{entity_id}` is deprecated and will be removed in a future version.\n\nFireplace mode has been moved to a climate preset on the climate entity to better match the device interface.\n\nPlease update your automations to use the `climate.set_preset_mode` action with preset mode `fireplace` instead of using the switch entity.\n\nAfter updating your automations, you can safely disable this switch entity.", + "description": "The fireplace mode switch entity `{entity_id}` is deprecated and will be removed in Home Assistant 2026.9.\n\nFireplace mode has been moved to a climate preset on the climate entity to better match the device interface.\n\nPlease update your automations to use the `climate.set_preset_mode` action with preset mode `fireplace` instead of using the switch entity.\n\nAfter updating your automations, you can safely disable this switch entity.", "title": "Fireplace mode switch is deprecated" } } diff --git a/homeassistant/components/flexit_bacnet/switch.py b/homeassistant/components/flexit_bacnet/switch.py index b19012c9d4fcfa..e331afb37f7067 100644 --- a/homeassistant/components/flexit_bacnet/switch.py +++ b/homeassistant/components/flexit_bacnet/switch.py @@ -91,6 +91,7 @@ async def async_setup_entry( hass, DOMAIN, f"deprecated_switch_{fireplace_switch_unique_id}", + breaks_in_ha_version="2026.9.0", is_fixable=False, issue_domain=DOMAIN, severity=IssueSeverity.WARNING, @@ -102,7 +103,7 @@ async def async_setup_entry( entities.append(FlexitSwitch(coordinator, description)) else: entities.append(FlexitSwitch(coordinator, description)) - async_add_entities(entities) + async_add_entities(entities) PARALLEL_UPDATES = 1 diff --git a/homeassistant/components/flo/binary_sensor.py b/homeassistant/components/flo/binary_sensor.py index 89f317fd3c617c..5025006c294a0e 100644 --- a/homeassistant/components/flo/binary_sensor.py +++ b/homeassistant/components/flo/binary_sensor.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -49,12 +51,12 @@ def __init__(self, device): super().__init__("pending_system_alerts", device) @property - def is_on(self): + def is_on(self) -> bool: """Return true if the Flo device has pending alerts.""" return self._device.has_alerts @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" if not self._device.has_alerts: return {} @@ -76,6 +78,6 @@ def __init__(self, device): super().__init__("water_detected", device) @property - def is_on(self): + def is_on(self) -> bool: """Return true if the Flo device is detecting water.""" return self._device.water_detected diff --git a/homeassistant/components/flux/switch.py b/homeassistant/components/flux/switch.py index 011973e3bf0022..53b90c82befec9 100644 --- a/homeassistant/components/flux/switch.py +++ b/homeassistant/components/flux/switch.py @@ -223,7 +223,7 @@ def name(self): return self._name @property - def is_on(self): + def is_on(self) -> bool: """Return true if switch is on.""" return self.unsub_tracker is not None diff --git a/homeassistant/components/forecast_solar/manifest.json b/homeassistant/components/forecast_solar/manifest.json index 66796a44dc4854..65df6a8828a9fe 100644 --- a/homeassistant/components/forecast_solar/manifest.json +++ b/homeassistant/components/forecast_solar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/forecast_solar", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["forecast-solar==4.2.0"] + "requirements": ["forecast-solar==5.0.0"] } diff --git a/homeassistant/components/forked_daapd/browse_media.py b/homeassistant/components/forked_daapd/browse_media.py index 35ad0ed49b0d9b..e6918f9e5d66c7 100644 --- a/homeassistant/components/forked_daapd/browse_media.py +++ b/homeassistant/components/forked_daapd/browse_media.py @@ -304,7 +304,7 @@ def base_owntone_library() -> BrowseMedia: can_play=False, can_expand=True, children=children, - thumbnail="https://brands.home-assistant.io/_/forked_daapd/logo.png", + thumbnail="/api/brands/integration/forked_daapd/logo.png", ) @@ -321,7 +321,7 @@ def library(other: Sequence[BrowseMedia] | None) -> BrowseMedia: media_content_type=MediaType.APP, can_play=False, can_expand=True, - thumbnail="https://brands.home-assistant.io/_/forked_daapd/logo.png", + thumbnail="/api/brands/integration/forked_daapd/logo.png", ) ] if other: diff --git a/homeassistant/components/freebox/config_flow.py b/homeassistant/components/freebox/config_flow.py index 62a1cd14b3df43..7ca26f7f34ee9a 100644 --- a/homeassistant/components/freebox/config_flow.py +++ b/homeassistant/components/freebox/config_flow.py @@ -103,6 +103,8 @@ async def async_step_zeroconf( ) -> ConfigFlowResult: """Initialize flow from zeroconf.""" zeroconf_properties = discovery_info.properties - host = zeroconf_properties["api_domain"] - port = zeroconf_properties["https_port"] + host = zeroconf_properties.get("api_domain") + if not host: + return self.async_abort(reason="missing_api_domain") + port = zeroconf_properties.get("https_port") or discovery_info.port return await self.async_step_user({CONF_HOST: host, CONF_PORT: port}) diff --git a/homeassistant/components/freebox/strings.json b/homeassistant/components/freebox/strings.json index a1383045e0942b..12ca866278ade4 100644 --- a/homeassistant/components/freebox/strings.json +++ b/homeassistant/components/freebox/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%]", + "missing_api_domain": "The discovered Freebox service did not provide the required API domain. Try again later or configure the Freebox manually." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", diff --git a/homeassistant/components/freshr/__init__.py b/homeassistant/components/freshr/__init__.py new file mode 100644 index 00000000000000..52d62cff7589e4 --- /dev/null +++ b/homeassistant/components/freshr/__init__.py @@ -0,0 +1,47 @@ +"""The Fresh-r integration.""" + +import asyncio + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import ( + FreshrConfigEntry, + FreshrData, + FreshrDevicesCoordinator, + FreshrReadingsCoordinator, +) + +_PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: FreshrConfigEntry) -> bool: + """Set up Fresh-r from a config entry.""" + devices_coordinator = FreshrDevicesCoordinator(hass, entry) + await devices_coordinator.async_config_entry_first_refresh() + + readings: dict[str, FreshrReadingsCoordinator] = { + device.id: FreshrReadingsCoordinator( + hass, entry, device, devices_coordinator.client + ) + for device in devices_coordinator.data + } + await asyncio.gather( + *( + coordinator.async_config_entry_first_refresh() + for coordinator in readings.values() + ) + ) + + entry.runtime_data = FreshrData( + devices=devices_coordinator, + readings=readings, + ) + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: FreshrConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/freshr/config_flow.py b/homeassistant/components/freshr/config_flow.py new file mode 100644 index 00000000000000..e3d366ff03dfda --- /dev/null +++ b/homeassistant/components/freshr/config_flow.py @@ -0,0 +1,98 @@ +"""Config flow for the Fresh-r integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from aiohttp import ClientError +from pyfreshr import FreshrClient +from pyfreshr.exceptions import LoginError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, LOGGER + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class FreshrFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Fresh-r.""" + + VERSION = 1 + MINOR_VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + 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" + else: + await self.async_set_unique_id(user_input[CONF_USERNAME].lower()) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"Fresh-r ({user_input[CONF_USERNAME]})", + data=user_input, + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_reauth( + self, _user_input: 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: + 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" + else: + 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=vol.Schema({vol.Required(CONF_PASSWORD): str}), + description_placeholders={CONF_USERNAME: reauth_entry.data[CONF_USERNAME]}, + errors=errors, + ) diff --git a/homeassistant/components/freshr/const.py b/homeassistant/components/freshr/const.py new file mode 100644 index 00000000000000..50873e80c67ff1 --- /dev/null +++ b/homeassistant/components/freshr/const.py @@ -0,0 +1,7 @@ +"""Constants for the Fresh-r integration.""" + +import logging +from typing import Final + +DOMAIN: Final = "freshr" +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/freshr/coordinator.py b/homeassistant/components/freshr/coordinator.py new file mode 100644 index 00000000000000..3f68f218687b87 --- /dev/null +++ b/homeassistant/components/freshr/coordinator.py @@ -0,0 +1,116 @@ +"""Coordinator for Fresh-r integration.""" + +from dataclasses import dataclass +from datetime import timedelta + +from aiohttp import ClientError +from pyfreshr import FreshrClient +from pyfreshr.exceptions import ApiResponseError, LoginError +from pyfreshr.models import DeviceReadings, DeviceSummary + +from 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.aiohttp_client import async_create_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + +DEVICES_SCAN_INTERVAL = timedelta(hours=1) +READINGS_SCAN_INTERVAL = timedelta(minutes=10) + + +@dataclass +class FreshrData: + """Runtime data stored on the config entry.""" + + devices: FreshrDevicesCoordinator + readings: dict[str, FreshrReadingsCoordinator] + + +type FreshrConfigEntry = ConfigEntry[FreshrData] + + +class FreshrDevicesCoordinator(DataUpdateCoordinator[list[DeviceSummary]]): + """Coordinator that refreshes the device list once an hour.""" + + config_entry: FreshrConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: FreshrConfigEntry) -> None: + """Initialize the device list coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=f"{DOMAIN}_devices", + update_interval=DEVICES_SCAN_INTERVAL, + ) + self.client = FreshrClient(session=async_create_clientsession(hass)) + + async def _async_update_data(self) -> list[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] + + try: + if not self.client.logged_in: + await self.client.login(username, password) + + devices = await self.client.fetch_devices() + except LoginError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed", + ) from err + except (ApiResponseError, ClientError) as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + else: + return devices + + +class FreshrReadingsCoordinator(DataUpdateCoordinator[DeviceReadings]): + """Coordinator that refreshes readings for a single device every 10 minutes.""" + + config_entry: FreshrConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: FreshrConfigEntry, + device: DeviceSummary, + client: FreshrClient, + ) -> None: + """Initialize the readings coordinator for a single device.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=f"{DOMAIN}_readings_{device.id}", + update_interval=READINGS_SCAN_INTERVAL, + ) + self._device = device + self._client = client + + @property + def device_id(self) -> str: + """Return the device ID.""" + return self._device.id + + async def _async_update_data(self) -> DeviceReadings: + """Fetch current readings for this device from the Fresh-r API.""" + try: + return await self._client.fetch_device_current(self._device) + except LoginError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed", + ) from err + except (ApiResponseError, ClientError) as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err diff --git a/homeassistant/components/freshr/icons.json b/homeassistant/components/freshr/icons.json new file mode 100644 index 00000000000000..b582d21302f414 --- /dev/null +++ b/homeassistant/components/freshr/icons.json @@ -0,0 +1,18 @@ +{ + "entity": { + "sensor": { + "dew_point": { + "default": "mdi:thermometer-water" + }, + "flow": { + "default": "mdi:fan" + }, + "inside_temperature": { + "default": "mdi:home-thermometer" + }, + "outside_temperature": { + "default": "mdi:thermometer" + } + } + } +} diff --git a/homeassistant/components/freshr/manifest.json b/homeassistant/components/freshr/manifest.json new file mode 100644 index 00000000000000..7f5d2ab81ac482 --- /dev/null +++ b/homeassistant/components/freshr/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "freshr", + "name": "Fresh-r", + "codeowners": ["@SierraNL"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/freshr", + "integration_type": "hub", + "iot_class": "cloud_polling", + "quality_scale": "silver", + "requirements": ["pyfreshr==1.2.0"] +} diff --git a/homeassistant/components/freshr/quality_scale.yaml b/homeassistant/components/freshr/quality_scale.yaml new file mode 100644 index 00000000000000..f8d2b1a97d7306 --- /dev/null +++ b/homeassistant/components/freshr/quality_scale.yaml @@ -0,0 +1,72 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not register custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Integration uses a polling coordinator, not event-driven updates. + 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 register custom actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Integration connects to a cloud service; no local network discovery is possible. + discovery: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: todo + 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: + status: exempt + comment: No actionable repair scenarios exist; authentication failures are handled via the reauthentication flow. + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/freshr/sensor.py b/homeassistant/components/freshr/sensor.py new file mode 100644 index 00000000000000..210c3fccf08bdc --- /dev/null +++ b/homeassistant/components/freshr/sensor.py @@ -0,0 +1,158 @@ +"""Sensor platform for the Fresh-r integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from pyfreshr.models import DeviceReadings, DeviceType + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, + StateType, +) +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + UnitOfTemperature, + 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 .const import DOMAIN +from .coordinator import FreshrConfigEntry, FreshrReadingsCoordinator + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class FreshrSensorEntityDescription(SensorEntityDescription): + """Describes a Fresh-r sensor.""" + + value_fn: Callable[[DeviceReadings], StateType] + + +_T1 = FreshrSensorEntityDescription( + key="t1", + translation_key="inside_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda r: r.t1, +) +_T2 = FreshrSensorEntityDescription( + key="t2", + translation_key="outside_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda r: r.t2, +) +_CO2 = FreshrSensorEntityDescription( + key="co2", + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda r: r.co2, +) +_HUM = FreshrSensorEntityDescription( + key="hum", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda r: r.hum, +) +_FLOW = FreshrSensorEntityDescription( + key="flow", + translation_key="flow", + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda r: r.flow, +) +_DP = FreshrSensorEntityDescription( + key="dp", + translation_key="dew_point", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda r: r.dp, +) +_TEMP = FreshrSensorEntityDescription( + key="temp", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + 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), + DeviceType.MONITOR: (_CO2, _HUM, _DP, _TEMP), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: FreshrConfigEntry, + 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, + ) + for description in descriptions + ) + async_add_entities(entities) + + +class FreshrSensor(CoordinatorEntity[FreshrReadingsCoordinator], 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 + def native_value(self) -> StateType: + """Return the value from coordinator data.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/freshr/strings.json b/homeassistant/components/freshr/strings.json new file mode 100644 index 00000000000000..ee833d999c9cc9 --- /dev/null +++ b/homeassistant/components/freshr/strings.json @@ -0,0 +1,58 @@ +{ + "config": { + "abort": { + "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%]", + "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%]" + }, + "data_description": { + "password": "[%key:component::freshr::config::step::user::data_description::password%]" + }, + "description": "Re-enter the password for your Fresh-r account `{username}`." + }, + "user": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "Your Fresh-r account password.", + "username": "Your Fresh-r account username (email address)." + } + } + } + }, + "entity": { + "sensor": { + "dew_point": { + "name": "Dew point" + }, + "flow": { + "name": "Air flow rate" + }, + "inside_temperature": { + "name": "Inside temperature" + }, + "outside_temperature": { + "name": "Outside temperature" + } + } + }, + "exceptions": { + "auth_failed": { + "message": "Authentication failed. Check your Fresh-r username and password." + }, + "cannot_connect": { + "message": "Could not connect to the Fresh-r service." + } + } +} diff --git a/homeassistant/components/fritz/__init__.py b/homeassistant/components/fritz/__init__.py index 94f4f8ba0d894d..69be911f8f14c8 100644 --- a/homeassistant/components/fritz/__init__.py +++ b/homeassistant/components/fritz/__init__.py @@ -86,7 +86,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> bo avm_wrapper = entry.runtime_data fritz_data = hass.data[FRITZ_DATA_KEY] - fritz_data.tracked.pop(avm_wrapper.unique_id) + + if avm_wrapper.unique_id in fritz_data.tracked: + fritz_data.tracked.pop(avm_wrapper.unique_id) if not bool(fritz_data.tracked): hass.data.pop(FRITZ_DATA_KEY) diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index 270e9870c63166..cd8dda57402618 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -283,6 +283,7 @@ async def async_step_user( self._username = user_input[CONF_USERNAME] self._password = user_input[CONF_PASSWORD] self._use_tls = user_input[CONF_SSL] + self._feature_device_discovery = user_input[CONF_FEATURE_DEVICE_TRACKING] self._port = self._determine_port(user_input) diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index 7ee5d07247e9af..0cc359b318acc2 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -12,11 +12,7 @@ from typing import Any, TypedDict, cast from fritzconnection import FritzConnection -from fritzconnection.core.exceptions import ( - FritzActionError, - FritzConnectionException, - FritzSecurityError, -) +from fritzconnection.core.exceptions import FritzActionError from fritzconnection.lib.fritzcall import FritzCall from fritzconnection.lib.fritzhosts import FritzHosts from fritzconnection.lib.fritzstatus import FritzStatus @@ -47,11 +43,12 @@ DEFAULT_SSL, DEFAULT_USERNAME, DOMAIN, + FRITZ_AUTH_EXCEPTIONS, FRITZ_EXCEPTIONS, SCAN_INTERVAL, MeshRoles, ) -from .helpers import _ha_is_stopping +from .helpers import ha_is_stopping from .models import ( ConnectionInfo, Device, @@ -425,12 +422,18 @@ async def _async_update_hosts_info(self) -> dict[str, Device]: hosts_info: list[HostInfo] = [] try: try: - hosts_attributes = await self.hass.async_add_executor_job( - self.fritz_hosts.get_hosts_attributes + hosts_attributes = cast( + list[HostAttributes], + await self.hass.async_add_executor_job( + self.fritz_hosts.get_hosts_attributes + ), ) except FritzActionError: - hosts_info = await self.hass.async_add_executor_job( - self.fritz_hosts.get_hosts_info + hosts_info = cast( + list[HostInfo], + await self.hass.async_add_executor_job( + self.fritz_hosts.get_hosts_info + ), ) except Exception as ex: if not self.hass.is_stopping: @@ -552,7 +555,7 @@ async def async_scan_devices(self, now: datetime | None = None) -> None: """Scan for new network devices.""" if self.hass.is_stopping: - _ha_is_stopping("scan devices") + ha_is_stopping("scan devices") return _LOGGER.debug("Checking devices for FRITZ!Box device %s", self.host) @@ -586,7 +589,7 @@ async def async_scan_devices(self, now: datetime | None = None) -> None: topology := await self.hass.async_add_executor_job( self.fritz_hosts.get_mesh_topology ) - ): + ) or not isinstance(topology, dict): raise Exception("Mesh supported but empty topology reported") # noqa: TRY002 except FritzActionError: self.mesh_role = MeshRoles.SLAVE @@ -727,7 +730,7 @@ async def _async_service_call( """Return service details.""" if self.hass.is_stopping: - _ha_is_stopping(f"{service_name}/{action_name}") + ha_is_stopping(f"{service_name}/{action_name}") return {} if f"{service_name}{service_suffix}" not in self.connection.services: @@ -742,7 +745,7 @@ async def _async_service_call( **kwargs, ) ) - except FritzSecurityError: + except FRITZ_AUTH_EXCEPTIONS: _LOGGER.exception( "Authorization Error: Please check the provided credentials and" " verify that you can log into the web interface" @@ -755,12 +758,6 @@ async def _async_service_call( action_name, ) return {} - except FritzConnectionException: - _LOGGER.exception( - "Connection Error: Please check the device is properly configured" - " for remote login" - ) - return {} return result async def async_get_upnp_configuration(self) -> dict[str, Any]: diff --git a/homeassistant/components/fritz/entity.py b/homeassistant/components/fritz/entity.py index ef662737ad87f8..ade76993972650 100644 --- a/homeassistant/components/fritz/entity.py +++ b/homeassistant/components/fritz/entity.py @@ -51,11 +51,6 @@ async def async_process_update(self) -> None: """Update device.""" raise NotImplementedError - async def async_on_demand_update(self) -> None: - """Update state.""" - await self.async_process_update() - self.async_write_ha_state() - class FritzBoxBaseEntity: """Fritz host entity base class.""" diff --git a/homeassistant/components/fritz/helpers.py b/homeassistant/components/fritz/helpers.py index af75b97e59aab2..47f2e462cd8be7 100644 --- a/homeassistant/components/fritz/helpers.py +++ b/homeassistant/components/fritz/helpers.py @@ -34,6 +34,6 @@ def device_filter_out_from_trackers( return bool(reason) -def _ha_is_stopping(activity: str) -> None: +def ha_is_stopping(activity: str) -> None: """Inform that HA is stopping.""" _LOGGER.warning("Cannot execute %s: HomeAssistant is shutting down", activity) diff --git a/homeassistant/components/fritz/manifest.json b/homeassistant/components/fritz/manifest.json index dacddfae20157a..8688eddbdab708 100644 --- a/homeassistant/components/fritz/manifest.json +++ b/homeassistant/components/fritz/manifest.json @@ -8,7 +8,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["fritzconnection"], - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["fritzconnection[qr]==1.15.1", "xmltodict==1.0.2"], "ssdp": [ { diff --git a/homeassistant/components/fritz/quality_scale.yaml b/homeassistant/components/fritz/quality_scale.yaml index f1893ef317f3a8..547ef63ad22a5c 100644 --- a/homeassistant/components/fritz/quality_scale.yaml +++ b/homeassistant/components/fritz/quality_scale.yaml @@ -29,9 +29,7 @@ rules: log-when-unavailable: done parallel-updates: done reauthentication-flow: done - test-coverage: - status: todo - comment: we are close to the goal of 95% + test-coverage: done # Gold devices: done diff --git a/homeassistant/components/fritz/services.yaml b/homeassistant/components/fritz/services.yaml index 815c3b2487452b..bf1395c0b8d76e 100644 --- a/homeassistant/components/fritz/services.yaml +++ b/homeassistant/components/fritz/services.yaml @@ -4,9 +4,9 @@ set_guest_wifi_password: required: true selector: device: - integration: fritz entity: - device_class: connectivity + integration: fritz + domain: update password: required: false selector: @@ -23,9 +23,9 @@ dial: required: true selector: device: - integration: fritz entity: - device_class: connectivity + integration: fritz + domain: update number: required: true selector: diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index 6d188c65538dae..551cebc833cf15 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -133,26 +133,20 @@ async def _async_wifi_entities_list( ] ) _LOGGER.debug("WiFi networks count: %s", wifi_count) - networks: dict = {} + 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] = { - "ssid": network_info["NewSSID"], - "bssid": network_info["NewBSSID"], - "standard": network_info["NewStandard"], - "enabled": network_info["NewEnable"], - "status": network_info["NewStatus"], - } + networks[i] = network_info for i, network in networks.copy().items(): - networks[i]["switch_name"] = network["ssid"] + networks[i]["switch_name"] = network["NewSSID"] if ( len( [ j for j, n in networks.items() - if slugify(n["ssid"]) == slugify(network["ssid"]) + if slugify(n["NewSSID"]) == slugify(network["NewSSID"]) ] ) > 1 @@ -434,13 +428,11 @@ async def _async_fetch_update(self) -> None: for key, attr in attributes_dict.items(): self._attributes[attr] = self.port_mapping[key] - async def _async_switch_on_off_executor(self, turn_on: bool) -> bool: + async def _async_switch_on_off_executor(self, turn_on: bool) -> None: self.port_mapping["NewEnabled"] = "1" if turn_on else "0" - - resp = await self._avm_wrapper.async_add_port_mapping( + await self._avm_wrapper.async_add_port_mapping( self.connection_type, self.port_mapping ) - return bool(resp is not None) class FritzBoxDeflectionSwitch(FritzBoxBaseCoordinatorSwitch): @@ -525,12 +517,11 @@ async def async_turn_off(self, **kwargs: Any) -> None: """Turn off switch.""" await self._async_handle_turn_on_off(turn_on=False) - async def _async_handle_turn_on_off(self, turn_on: bool) -> bool: + async def _async_handle_turn_on_off(self, turn_on: bool) -> None: """Handle switch state change request.""" await self._avm_wrapper.async_set_allow_wan_access(self.ip_address, turn_on) self._avm_wrapper.devices[self._mac].wan_access = turn_on self.async_write_ha_state() - return True class FritzBoxWifiSwitch(FritzBoxBaseSwitch): @@ -541,10 +532,11 @@ def __init__( avm_wrapper: AvmWrapper, device_friendly_name: str, network_num: int, - network_data: dict, + network_data: dict[str, Any], ) -> None: """Init Fritz Wifi switch.""" self._avm_wrapper = avm_wrapper + self._wifi_info = network_data self._attributes = {} self._attr_entity_category = EntityCategory.CONFIG @@ -560,7 +552,7 @@ def __init__( type=SWITCH_TYPE_WIFINETWORK, callback_update=self._async_fetch_update, callback_switch=self._async_switch_on_off_executor, - init_state=network_data["enabled"], + init_state=network_data["NewEnable"], ) super().__init__(self._avm_wrapper, device_friendly_name, switch_info) @@ -587,7 +579,9 @@ async def _async_fetch_update(self) -> None: self._attributes["mac_address_control"] = wifi_info[ "NewMACAddressControlEnabled" ] + self._wifi_info = wifi_info async def _async_switch_on_off_executor(self, turn_on: bool) -> None: """Handle wifi switch.""" + self._wifi_info["NewEnable"] = turn_on await self._avm_wrapper.async_set_wlan_configuration(self._network_num, turn_on) diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index 3401eb99e6ab60..693d8bac5665e6 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -179,7 +179,9 @@ def preset_mode(self) -> str | None: return PRESET_HOLIDAY if self.data.summer_active: return PRESET_SUMMER - if self.data.target_temperature == ON_API_TEMPERATURE: + if self.data.target_temperature == ON_API_TEMPERATURE or getattr( + self.data, "boost_active", False + ): return PRESET_BOOST if self.data.target_temperature == self.data.comfort_temperature: return PRESET_COMFORT diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py index fcbea2d0265c2c..756264f5e35f9c 100644 --- a/homeassistant/components/fritzbox/coordinator.py +++ b/homeassistant/components/fritzbox/coordinator.py @@ -63,6 +63,7 @@ async def async_setup(self) -> None: host=self.config_entry.data[CONF_HOST], user=self.config_entry.data[CONF_USERNAME], password=self.config_entry.data[CONF_PASSWORD], + timeout=20, ) try: diff --git a/homeassistant/components/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json index cfb9fbea39b856..845ae1e65e0433 100644 --- a/homeassistant/components/fritzbox/manifest.json +++ b/homeassistant/components/fritzbox/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyfritzhome"], - "requirements": ["pyfritzhome==0.6.19"], + "requirements": ["pyfritzhome==0.6.20"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" diff --git a/homeassistant/components/fritzbox_callmonitor/manifest.json b/homeassistant/components/fritzbox_callmonitor/manifest.json index 024d4e84884c71..7895d7d54f625e 100644 --- a/homeassistant/components/fritzbox_callmonitor/manifest.json +++ b/homeassistant/components/fritzbox_callmonitor/manifest.json @@ -1,7 +1,7 @@ { "domain": "fritzbox_callmonitor", "name": "FRITZ!Box Call Monitor", - "codeowners": ["@cdce8p"], + "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fritzbox_callmonitor", "integration_type": "device", diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index f487064cafd545..6531f80ddaf491 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -18,7 +18,7 @@ from homeassistant.components import onboarding, websocket_api from homeassistant.components.http import KEY_HASS, HomeAssistantView, StaticPathConfig -from homeassistant.components.websocket_api import ActiveConnection +from homeassistant.components.websocket_api import ERR_NOT_FOUND, ActiveConnection from homeassistant.config import async_hass_config_yaml from homeassistant.const import ( CONF_MODE, @@ -78,6 +78,16 @@ THEMES_SAVE_DELAY = 60 DATA_THEMES_STORE: HassKey[Store] = HassKey("frontend_themes_store") DATA_THEMES: HassKey[dict[str, Any]] = HassKey("frontend_themes") + +PANELS_STORAGE_KEY = f"{DOMAIN}_panels" +PANELS_STORAGE_VERSION = 1 +PANELS_SAVE_DELAY = 10 +DATA_PANELS_STORE: HassKey[Store[dict[str, dict[str, Any]]]] = HassKey( + "frontend_panels_store" +) +DATA_PANELS_CONFIG: HassKey[dict[str, dict[str, Any]]] = HassKey( + "frontend_panels_config" +) DATA_DEFAULT_THEME = "frontend_default_theme" DATA_DEFAULT_DARK_THEME = "frontend_default_dark_theme" DEFAULT_THEME = "default" @@ -287,6 +297,9 @@ class Panel: # If the panel should only be visible to admins require_admin = False + # If the panel should be shown in the sidebar + show_in_sidebar = True + # If the panel is a configuration panel for a integration config_panel_domain: str | None = None @@ -300,6 +313,7 @@ def __init__( config: dict[str, Any] | None, require_admin: bool, config_panel_domain: str | None, + show_in_sidebar: bool, ) -> None: """Initialize a built-in panel.""" self.component_name = component_name @@ -309,12 +323,15 @@ def __init__( self.config = config self.require_admin = require_admin self.config_panel_domain = config_panel_domain + self.show_in_sidebar = show_in_sidebar self.sidebar_default_visible = sidebar_default_visible @callback - def to_response(self) -> PanelResponse: + def to_response( + self, config_override: dict[str, Any] | None = None + ) -> PanelResponse: """Panel as dictionary.""" - return { + response: PanelResponse = { "component_name": self.component_name, "icon": self.sidebar_icon, "title": self.sidebar_title, @@ -323,7 +340,18 @@ def to_response(self) -> PanelResponse: "url_path": self.frontend_url_path, "require_admin": self.require_admin, "config_panel_domain": self.config_panel_domain, + "show_in_sidebar": self.show_in_sidebar, } + if config_override: + if "require_admin" in config_override: + response["require_admin"] = config_override["require_admin"] + if "show_in_sidebar" in config_override: + response["show_in_sidebar"] = config_override["show_in_sidebar"] + if "icon" in config_override: + response["icon"] = config_override["icon"] + if "title" in config_override: + response["title"] = config_override["title"] + return response @bind_hass @@ -340,6 +368,7 @@ def async_register_built_in_panel( *, update: bool = False, config_panel_domain: str | None = None, + show_in_sidebar: bool = True, ) -> None: """Register a built-in panel.""" panel = Panel( @@ -351,6 +380,7 @@ def async_register_built_in_panel( config, require_admin, config_panel_domain, + show_in_sidebar, ) panels = hass.data.setdefault(DATA_PANELS, {}) @@ -415,12 +445,24 @@ def _frontend_root(dev_repo_path: str | None) -> pathlib.Path: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the serving of the frontend.""" await async_setup_frontend_storage(hass) + + panels_store = hass.data[DATA_PANELS_STORE] = Store[dict[str, dict[str, Any]]]( + hass, PANELS_STORAGE_VERSION, PANELS_STORAGE_KEY + ) + loaded: Any = await panels_store.async_load() + if not isinstance(loaded, dict): + if loaded is not None: + _LOGGER.warning("Ignoring invalid panel storage data") + loaded = {} + hass.data[DATA_PANELS_CONFIG] = loaded + websocket_api.async_register_command(hass, websocket_get_icons) websocket_api.async_register_command(hass, websocket_get_panels) websocket_api.async_register_command(hass, websocket_get_themes) websocket_api.async_register_command(hass, websocket_get_translations) websocket_api.async_register_command(hass, websocket_get_version) websocket_api.async_register_command(hass, websocket_subscribe_extra_js) + websocket_api.async_register_command(hass, websocket_update_panel) hass.http.register_view(ManifestJSONView()) conf = config.get(DOMAIN, {}) @@ -534,31 +576,32 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "light", sidebar_icon="mdi:lamps", sidebar_title="light", - sidebar_default_visible=False, + show_in_sidebar=False, ) async_register_built_in_panel( hass, "security", sidebar_icon="mdi:security", sidebar_title="security", - sidebar_default_visible=False, + show_in_sidebar=False, ) async_register_built_in_panel( hass, "climate", sidebar_icon="mdi:home-thermometer", sidebar_title="climate", - sidebar_default_visible=False, + show_in_sidebar=False, ) async_register_built_in_panel( hass, "home", sidebar_icon="mdi:home", sidebar_title="home", - sidebar_default_visible=False, + show_in_sidebar=False, ) async_register_built_in_panel(hass, "profile") + async_register_built_in_panel(hass, "notfound") @callback def async_change_listener( @@ -883,11 +926,18 @@ def websocket_get_panels( ) -> None: """Handle get panels command.""" user_is_admin = connection.user.is_admin - panels = { - panel_key: panel.to_response() - for panel_key, panel in connection.hass.data[DATA_PANELS].items() - if user_is_admin or not panel.require_admin - } + panels_config = hass.data[DATA_PANELS_CONFIG] + panels: dict[str, PanelResponse] = {} + for panel_key, panel in connection.hass.data[DATA_PANELS].items(): + config_override = panels_config.get(panel_key) + require_admin = ( + config_override.get("require_admin", panel.require_admin) + if config_override + else panel.require_admin + ) + if not user_is_admin and require_admin: + continue + panels[panel_key] = panel.to_response(config_override) connection.send_message(websocket_api.result_message(msg["id"], panels)) @@ -986,6 +1036,50 @@ def cancel_subscription() -> None: connection.send_message(websocket_api.result_message(msg["id"])) +@websocket_api.websocket_command( + { + vol.Required("type"): "frontend/update_panel", + vol.Required("url_path"): str, + vol.Optional("title"): vol.Any(cv.string, None), + vol.Optional("icon"): vol.Any(cv.icon, None), + vol.Optional("require_admin"): vol.Any(cv.boolean, None), + vol.Optional("show_in_sidebar"): vol.Any(cv.boolean, None), + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_update_panel( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle update panel command.""" + url_path: str = msg["url_path"] + + if url_path not in hass.data.get(DATA_PANELS, {}): + connection.send_error(msg["id"], ERR_NOT_FOUND, "Panel not found") + return + + panels_config = hass.data[DATA_PANELS_CONFIG] + panel_config = dict(panels_config.get(url_path, {})) + + for key in ("title", "icon", "require_admin", "show_in_sidebar"): + if key in msg: + if (value := msg[key]) is None: + panel_config.pop(key, None) + else: + panel_config[key] = value + + if panel_config: + panels_config[url_path] = panel_config + else: + panels_config.pop(url_path, None) + + hass.data[DATA_PANELS_STORE].async_delay_save( + lambda: hass.data[DATA_PANELS_CONFIG], PANELS_SAVE_DELAY + ) + hass.bus.async_fire(EVENT_PANELS_UPDATED) + connection.send_result(msg["id"]) + + class PanelResponse(TypedDict): """Represent the panel response type.""" @@ -997,3 +1091,4 @@ class PanelResponse(TypedDict): url_path: str require_admin: bool config_panel_domain: str | None + show_in_sidebar: bool diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 96e6462ff7136b..f529bcd541ace7 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==20260128.6"] + "requirements": ["home-assistant-frontend==20260312.0"] } diff --git a/homeassistant/components/frontend/pr_download.py b/homeassistant/components/frontend/pr_download.py index 4de28d7c405c8a..1d4c28a047151b 100644 --- a/homeassistant/components/frontend/pr_download.py +++ b/homeassistant/components/frontend/pr_download.py @@ -43,13 +43,13 @@ ) -async def _get_pr_head_sha(client: GitHubAPI, pr_number: int) -> str: - """Get the head SHA for the PR.""" +async def _get_pr_shas(client: GitHubAPI, pr_number: int) -> tuple[str, str]: + """Get the head and base SHAs for a PR.""" try: response = await client.generic( endpoint=f"/repos/home-assistant/frontend/pulls/{pr_number}", ) - return str(response.data["head"]["sha"]) + return str(response.data["head"]["sha"]), str(response.data["base"]["sha"]) except GitHubAuthenticationException as err: raise HomeAssistantError(ERROR_INVALID_TOKEN) from err except (GitHubRatelimitException, GitHubPermissionException) as err: @@ -137,9 +137,9 @@ async def _download_artifact_data( def _extract_artifact( artifact_data: bytes, cache_dir: pathlib.Path, - head_sha: str, + cache_key: str, ) -> None: - """Extract artifact and save SHA (runs in executor).""" + """Extract artifact and save cache key (runs in executor).""" frontend_dir = cache_dir / "hass_frontend" if cache_dir.exists(): @@ -163,9 +163,8 @@ def _extract_artifact( ) zip_file.extractall(str(frontend_dir)) - # Save the commit SHA for cache validation sha_file = cache_dir / ".sha" - sha_file.write_text(head_sha) + sha_file.write_text(cache_key) async def download_pr_artifact( @@ -186,27 +185,29 @@ async def download_pr_artifact( client = GitHubAPI(token=github_token, session=session) - head_sha = await _get_pr_head_sha(client, pr_number) + head_sha, base_sha = await _get_pr_shas(client, pr_number) + cache_key = f"{head_sha}:{base_sha}" frontend_dir = tmp_dir / "hass_frontend" sha_file = tmp_dir / ".sha" if frontend_dir.exists() and sha_file.exists(): try: - cached_sha = await hass.async_add_executor_job(sha_file.read_text) - if cached_sha.strip() == head_sha: + cached_key = await hass.async_add_executor_job(sha_file.read_text) + cached_key = cached_key.strip() + if cached_key == cache_key: _LOGGER.info( "Using cached PR #%s (commit %s) from %s", pr_number, - head_sha[:8], + cache_key, tmp_dir, ) return tmp_dir _LOGGER.info( - "PR #%s has new commits (cached: %s, current: %s), re-downloading", + "PR #%s cache outdated (cached: %s, current: %s), re-downloading", pr_number, - cached_sha[:8], - head_sha[:8], + cached_key, + cache_key, ) except OSError as err: _LOGGER.debug("Failed to read cache SHA file: %s", err) @@ -218,7 +219,7 @@ async def download_pr_artifact( try: await hass.async_add_executor_job( - _extract_artifact, artifact_data, tmp_dir, head_sha + _extract_artifact, artifact_data, tmp_dir, cache_key ) except zipfile.BadZipFile as err: raise HomeAssistantError( diff --git a/homeassistant/components/frontend/storage.py b/homeassistant/components/frontend/storage.py index 2c626102ac66f6..71b6580a0a1e55 100644 --- a/homeassistant/components/frontend/storage.py +++ b/homeassistant/components/frontend/storage.py @@ -45,6 +45,10 @@ async def async_user_store(hass: HomeAssistant, user_id: str) -> UserStore: except BaseException as ex: del stores[user_id] future.set_exception(ex) + # Ensure the future is marked as retrieved + # since if there is no concurrent call it + # will otherwise never be retrieved. + future.exception() raise future.set_result(store) diff --git a/homeassistant/components/fully_kiosk/button.py b/homeassistant/components/fully_kiosk/button.py index 625a965a0dabeb..b93f1191f84398 100644 --- a/homeassistant/components/fully_kiosk/button.py +++ b/homeassistant/components/fully_kiosk/button.py @@ -27,6 +27,7 @@ class FullyButtonEntityDescription(ButtonEntityDescription): """Fully Kiosk Browser button description.""" press_action: Callable[[FullyKiosk], Any] + refresh_after_press: bool = True BUTTONS: tuple[FullyButtonEntityDescription, ...] = ( @@ -68,6 +69,13 @@ class FullyButtonEntityDescription(ButtonEntityDescription): entity_category=EntityCategory.CONFIG, press_action=lambda fully: fully.clearCache(), ), + FullyButtonEntityDescription( + key="triggerMotion", + translation_key="trigger_motion", + entity_category=EntityCategory.CONFIG, + press_action=lambda fully: fully.triggerMotion(), + refresh_after_press=False, + ), ) @@ -102,4 +110,5 @@ def __init__( async def async_press(self) -> None: """Set the value of the entity.""" await self.entity_description.press_action(self.coordinator.fully) - await self.coordinator.async_refresh() + if self.entity_description.refresh_after_press: + await self.coordinator.async_refresh() diff --git a/homeassistant/components/fully_kiosk/config_flow.py b/homeassistant/components/fully_kiosk/config_flow.py index 53185e8ab76691..7ab6ac90f146b9 100644 --- a/homeassistant/components/fully_kiosk/config_flow.py +++ b/homeassistant/components/fully_kiosk/config_flow.py @@ -19,6 +19,8 @@ CONF_SSL, CONF_VERIFY_SSL, ) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError 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 @@ -27,6 +29,34 @@ from .const import DEFAULT_PORT, DOMAIN, LOGGER +async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> Any: + """Validate the user input allows us to connect.""" + fully = FullyKiosk( + async_get_clientsession(hass), + data[CONF_HOST], + DEFAULT_PORT, + data[CONF_PASSWORD], + use_ssl=data[CONF_SSL], + verify_ssl=data[CONF_VERIFY_SSL], + ) + + try: + async with asyncio.timeout(15): + device_info = await fully.getDeviceInfo() + except ( + ClientConnectorError, + FullyKioskError, + TimeoutError, + ) as error: + LOGGER.debug(error.args, exc_info=True) + raise CannotConnect from error + except Exception as error: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + raise UnknownError from error + + return device_info + + class FullyKioskConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Fully Kiosk Browser.""" @@ -43,58 +73,42 @@ async def _create_entry( host: str, user_input: dict[str, Any], errors: dict[str, str], - description_placeholders: dict[str, str] | Any = None, ) -> ConfigFlowResult | None: - fully = FullyKiosk( - async_get_clientsession(self.hass), - host, - DEFAULT_PORT, - user_input[CONF_PASSWORD], - use_ssl=user_input[CONF_SSL], - verify_ssl=user_input[CONF_VERIFY_SSL], - ) - + """Create a config entry.""" + self._async_abort_entries_match({CONF_HOST: host}) try: - async with asyncio.timeout(15): - device_info = await fully.getDeviceInfo() - except ( - ClientConnectorError, - FullyKioskError, - TimeoutError, - ) as error: - LOGGER.debug(error.args, exc_info=True) + device_info = await _validate_input( + self.hass, {**user_input, CONF_HOST: host} + ) + except CannotConnect: errors["base"] = "cannot_connect" - description_placeholders["error_detail"] = str(error.args) return None - except Exception as error: # noqa: BLE001 - LOGGER.exception("Unexpected exception: %s", error) + except UnknownError: errors["base"] = "unknown" - description_placeholders["error_detail"] = str(error.args) return None - - await self.async_set_unique_id(device_info["deviceID"], raise_on_progress=False) - self._abort_if_unique_id_configured(updates=user_input) - return self.async_create_entry( - title=device_info["deviceName"], - data={ - CONF_HOST: host, - CONF_PASSWORD: user_input[CONF_PASSWORD], - CONF_MAC: format_mac(device_info["Mac"]), - CONF_SSL: user_input[CONF_SSL], - CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], - }, - ) + else: + await self.async_set_unique_id( + device_info["deviceID"], raise_on_progress=False + ) + self._abort_if_unique_id_configured(updates=user_input) + return self.async_create_entry( + title=device_info["deviceName"], + data={ + CONF_HOST: host, + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_MAC: format_mac(device_info["Mac"]), + CONF_SSL: user_input[CONF_SSL], + CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], + }, + ) async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" errors: dict[str, str] = {} - placeholders: dict[str, str] = {} if user_input is not None: - result = await self._create_entry( - user_input[CONF_HOST], user_input, errors, placeholders - ) + result = await self._create_entry(user_input[CONF_HOST], user_input, errors) if result: return result @@ -108,7 +122,6 @@ async def async_step_user( vol.Optional(CONF_VERIFY_SSL, default=False): bool, } ), - description_placeholders=placeholders, errors=errors, ) @@ -171,3 +184,66 @@ async def async_step_mqtt( self.host = device_info["hostname4"] self._discovered_device_info = device_info return await self.async_step_discovery_confirm() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] + ) -> ConfigFlowResult: + """Handle reconfiguration of an existing config entry.""" + errors: dict[str, str] = {} + reconf_entry = self._get_reconfigure_entry() + suggested_values = { + CONF_HOST: reconf_entry.data[CONF_HOST], + CONF_PASSWORD: reconf_entry.data[CONF_PASSWORD], + CONF_SSL: reconf_entry.data[CONF_SSL], + CONF_VERIFY_SSL: reconf_entry.data[CONF_VERIFY_SSL], + } + + if user_input: + try: + device_info = await _validate_input( + self.hass, + data={ + **reconf_entry.data, + **user_input, + }, + ) + except CannotConnect: + errors["base"] = "cannot_connect" + except UnknownError: + errors["base"] = "unknown" + else: + await self.async_set_unique_id( + device_info["deviceID"], raise_on_progress=False + ) + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + reconf_entry, + data_updates={ + **reconf_entry.data, + **user_input, + }, + ) + + 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, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_SSL, default=False): bool, + vol.Optional(CONF_VERIFY_SSL, default=False): bool, + } + ), + suggested_values=user_input or suggested_values, + ), + errors=errors, + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect to the Fully Kiosk device.""" + + +class UnknownError(HomeAssistantError): + """Error to indicate an unknown error occurred.""" diff --git a/homeassistant/components/fully_kiosk/manifest.json b/homeassistant/components/fully_kiosk/manifest.json index 9322d42e14838d..1f690118cefccd 100644 --- a/homeassistant/components/fully_kiosk/manifest.json +++ b/homeassistant/components/fully_kiosk/manifest.json @@ -14,5 +14,5 @@ "iot_class": "local_polling", "mqtt": ["fully/deviceInfo/+"], "quality_scale": "bronze", - "requirements": ["python-fullykiosk==0.0.14"] + "requirements": ["python-fullykiosk==0.0.15"] } diff --git a/homeassistant/components/fully_kiosk/sensor.py b/homeassistant/components/fully_kiosk/sensor.py index 6094a6c4c234ab..6bc9a254760c03 100644 --- a/homeassistant/components/fully_kiosk/sensor.py +++ b/homeassistant/components/fully_kiosk/sensor.py @@ -12,7 +12,12 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfInformation, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType @@ -56,6 +61,14 @@ class FullySensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), + FullySensorEntityDescription( + key="batteryTemperature", + translation_key="battery_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), FullySensorEntityDescription( key="currentPage", translation_key="current_page", diff --git a/homeassistant/components/fully_kiosk/strings.json b/homeassistant/components/fully_kiosk/strings.json index 10fe679bf1dc26..986478ac1c0910 100644 --- a/homeassistant/components/fully_kiosk/strings.json +++ b/homeassistant/components/fully_kiosk/strings.json @@ -6,11 +6,13 @@ }, "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "Please ensure you reconfigure the same device." }, "error": { - "cannot_connect": "Cannot connect. Details: {error_detail}", - "unknown": "Unknown. Details: {error_detail}" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { "discovery_confirm": { @@ -26,6 +28,20 @@ }, "description": "Do you want to set up {name} ({host})?" }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of the device running your Fully Kiosk Browser application.", + "password": "[%key:component::fully_kiosk::common::data_description_password%]", + "ssl": "[%key:component::fully_kiosk::common::data_description_ssl%]", + "verify_ssl": "[%key:component::fully_kiosk::common::data_description_verify_ssl%]" + } + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", @@ -72,6 +88,9 @@ }, "to_foreground": { "name": "Bring to foreground" + }, + "trigger_motion": { + "name": "Trigger motion activity" } }, "image": { @@ -102,6 +121,9 @@ } }, "sensor": { + "battery_temperature": { + "name": "Battery temperature" + }, "current_page": { "name": "Current page" }, diff --git a/homeassistant/components/futurenow/light.py b/homeassistant/components/futurenow/light.py index e9dcfd7a15162f..be15e2b2230c2d 100644 --- a/homeassistant/components/futurenow/light.py +++ b/homeassistant/components/futurenow/light.py @@ -77,12 +77,10 @@ class FutureNowLight(LightEntity): def __init__(self, device): """Initialize the light.""" - self._name = device["name"] + self._attr_name = device["name"] self._dimmable = device["dimmable"] self._channel = device["channel"] - self._brightness = None self._last_brightness = 255 - self._state = None if device["driver"] == CONF_DRIVER_FNIP6X10AD: self._light = pyfnip.FNIP6x2adOutput( @@ -93,21 +91,6 @@ def __init__(self, device): device["host"], device["port"], self._channel ) - @property - def name(self): - """Return the name of the device if any.""" - return self._name - - @property - def is_on(self): - """Return true if device is on.""" - return self._state - - @property - def brightness(self): - """Return the brightness of this light between 0..255.""" - return self._brightness - @property def color_mode(self) -> ColorMode: """Return the color mode of the light.""" @@ -131,11 +114,11 @@ def turn_on(self, **kwargs: Any) -> None: def turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" self._light.turn_off() - if self._brightness: - self._last_brightness = self._brightness + if self._attr_brightness: + self._last_brightness = self._attr_brightness def update(self) -> None: """Fetch new state data for this light.""" state = int(self._light.is_on()) - self._state = bool(state) - self._brightness = to_hass_level(state) + self._attr_is_on = bool(state) + self._attr_brightness = to_hass_level(state) diff --git a/homeassistant/components/garage_door/__init__.py b/homeassistant/components/garage_door/__init__.py new file mode 100644 index 00000000000000..ef353a5d31bae9 --- /dev/null +++ b/homeassistant/components/garage_door/__init__.py @@ -0,0 +1,15 @@ +"""Integration for garage door triggers.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +DOMAIN = "garage_door" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the component.""" + return True diff --git a/homeassistant/components/garage_door/condition.py b/homeassistant/components/garage_door/condition.py new file mode 100644 index 00000000000000..91dee68d267bf6 --- /dev/null +++ b/homeassistant/components/garage_door/condition.py @@ -0,0 +1,31 @@ +"""Provides conditions for garage doors.""" + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + CoverDeviceClass, + make_cover_is_closed_condition, + make_cover_is_open_condition, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.condition import Condition + +DEVICE_CLASSES_GARAGE_DOOR: dict[str, str] = { + BINARY_SENSOR_DOMAIN: BinarySensorDeviceClass.GARAGE_DOOR, + COVER_DOMAIN: CoverDeviceClass.GARAGE, +} + +CONDITIONS: dict[str, type[Condition]] = { + "is_closed": make_cover_is_closed_condition( + device_classes=DEVICE_CLASSES_GARAGE_DOOR + ), + "is_open": make_cover_is_open_condition(device_classes=DEVICE_CLASSES_GARAGE_DOOR), +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the conditions for garage doors.""" + return CONDITIONS diff --git a/homeassistant/components/garage_door/conditions.yaml b/homeassistant/components/garage_door/conditions.yaml new file mode 100644 index 00000000000000..32215fdc5eb8ff --- /dev/null +++ b/homeassistant/components/garage_door/conditions.yaml @@ -0,0 +1,28 @@ +.condition_common_fields: &condition_common_fields + behavior: + required: true + default: any + selector: + select: + translation_key: condition_behavior + options: + - all + - any + +is_closed: + fields: *condition_common_fields + target: + entity: + - domain: binary_sensor + device_class: garage_door + - domain: cover + device_class: garage + +is_open: + fields: *condition_common_fields + target: + entity: + - domain: binary_sensor + device_class: garage_door + - domain: cover + device_class: garage diff --git a/homeassistant/components/garage_door/icons.json b/homeassistant/components/garage_door/icons.json new file mode 100644 index 00000000000000..d14d9859eff6dd --- /dev/null +++ b/homeassistant/components/garage_door/icons.json @@ -0,0 +1,18 @@ +{ + "conditions": { + "is_closed": { + "condition": "mdi:garage" + }, + "is_open": { + "condition": "mdi:garage-open" + } + }, + "triggers": { + "closed": { + "trigger": "mdi:garage" + }, + "opened": { + "trigger": "mdi:garage-open" + } + } +} diff --git a/homeassistant/components/garage_door/manifest.json b/homeassistant/components/garage_door/manifest.json new file mode 100644 index 00000000000000..f9ea106efcbad2 --- /dev/null +++ b/homeassistant/components/garage_door/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "garage_door", + "name": "Garage door", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/garage_door", + "integration_type": "system", + "quality_scale": "internal" +} diff --git a/homeassistant/components/garage_door/strings.json b/homeassistant/components/garage_door/strings.json new file mode 100644 index 00000000000000..f0e50ad82a12e7 --- /dev/null +++ b/homeassistant/components/garage_door/strings.json @@ -0,0 +1,68 @@ +{ + "common": { + "condition_behavior_description": "How the state should match on the targeted garage doors.", + "condition_behavior_name": "Behavior", + "trigger_behavior_description": "The behavior of the targeted garage doors to trigger on.", + "trigger_behavior_name": "Behavior" + }, + "conditions": { + "is_closed": { + "description": "Tests if one or more garage doors are closed.", + "fields": { + "behavior": { + "description": "[%key:component::garage_door::common::condition_behavior_description%]", + "name": "[%key:component::garage_door::common::condition_behavior_name%]" + } + }, + "name": "Garage door is closed" + }, + "is_open": { + "description": "Tests if one or more garage doors are open.", + "fields": { + "behavior": { + "description": "[%key:component::garage_door::common::condition_behavior_description%]", + "name": "[%key:component::garage_door::common::condition_behavior_name%]" + } + }, + "name": "Garage door is open" + } + }, + "selector": { + "condition_behavior": { + "options": { + "all": "All", + "any": "Any" + } + }, + "trigger_behavior": { + "options": { + "any": "Any", + "first": "First", + "last": "Last" + } + } + }, + "title": "Garage door", + "triggers": { + "closed": { + "description": "Triggers after one or more garage doors close.", + "fields": { + "behavior": { + "description": "[%key:component::garage_door::common::trigger_behavior_description%]", + "name": "[%key:component::garage_door::common::trigger_behavior_name%]" + } + }, + "name": "Garage door closed" + }, + "opened": { + "description": "Triggers after one or more garage doors open.", + "fields": { + "behavior": { + "description": "[%key:component::garage_door::common::trigger_behavior_description%]", + "name": "[%key:component::garage_door::common::trigger_behavior_name%]" + } + }, + "name": "Garage door opened" + } + } +} diff --git a/homeassistant/components/garage_door/trigger.py b/homeassistant/components/garage_door/trigger.py new file mode 100644 index 00000000000000..6d725636086117 --- /dev/null +++ b/homeassistant/components/garage_door/trigger.py @@ -0,0 +1,30 @@ +"""Provides triggers for garage doors.""" + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + CoverDeviceClass, + make_cover_closed_trigger, + make_cover_opened_trigger, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.trigger import Trigger + +DEVICE_CLASSES_GARAGE_DOOR: dict[str, str] = { + BINARY_SENSOR_DOMAIN: BinarySensorDeviceClass.GARAGE_DOOR, + COVER_DOMAIN: CoverDeviceClass.GARAGE, +} + + +TRIGGERS: dict[str, type[Trigger]] = { + "opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_GARAGE_DOOR), + "closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_GARAGE_DOOR), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for garage doors.""" + return TRIGGERS diff --git a/homeassistant/components/garage_door/triggers.yaml b/homeassistant/components/garage_door/triggers.yaml new file mode 100644 index 00000000000000..5a36582d0dee89 --- /dev/null +++ b/homeassistant/components/garage_door/triggers.yaml @@ -0,0 +1,29 @@ +.trigger_common_fields: &trigger_common_fields + behavior: + required: true + default: any + selector: + select: + translation_key: trigger_behavior + options: + - first + - last + - any + +closed: + fields: *trigger_common_fields + target: + entity: + - domain: binary_sensor + device_class: garage_door + - domain: cover + device_class: garage + +opened: + fields: *trigger_common_fields + target: + entity: + - domain: binary_sensor + device_class: garage_door + - domain: cover + device_class: garage diff --git a/homeassistant/components/gardena_bluetooth/__init__.py b/homeassistant/components/gardena_bluetooth/__init__.py index 4a21bb3d3e430c..2e915beb22ee56 100644 --- a/homeassistant/components/gardena_bluetooth/__init__.py +++ b/homeassistant/components/gardena_bluetooth/__init__.py @@ -2,12 +2,18 @@ from __future__ import annotations +import asyncio import logging from bleak.backends.device import BLEDevice from gardena_bluetooth.client import CachedConnection, Client -from gardena_bluetooth.const import DeviceConfiguration, DeviceInformation -from gardena_bluetooth.exceptions import CommunicationFailure +from gardena_bluetooth.const import AquaContour, DeviceConfiguration, DeviceInformation +from gardena_bluetooth.exceptions import ( + CharacteristicNoAccess, + CharacteristicNotFound, + CommunicationFailure, +) +from gardena_bluetooth.parse import CharacteristicTime from homeassistant.components import bluetooth from homeassistant.const import CONF_ADDRESS, Platform @@ -23,11 +29,13 @@ GardenaBluetoothConfigEntry, GardenaBluetoothCoordinator, ) +from .util import async_get_product_type PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.VALVE, @@ -51,22 +59,43 @@ def _device_lookup() -> BLEDevice: return CachedConnection(DISCONNECT_DELAY, _device_lookup) +async def _update_timestamp(client: Client, characteristics: CharacteristicTime): + try: + await client.update_timestamp(characteristics, dt_util.now()) + except CharacteristicNotFound: + pass + except CharacteristicNoAccess: + LOGGER.debug("No access to update internal time") + + async def async_setup_entry( hass: HomeAssistant, entry: GardenaBluetoothConfigEntry ) -> bool: """Set up Gardena Bluetooth from a config entry.""" address = entry.data[CONF_ADDRESS] - client = Client(get_connection(hass, 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 + + client = Client(get_connection(hass, address), product_type) try: + chars = await client.get_all_characteristics() + sw_version = await client.read_char(DeviceInformation.firmware_version, None) manufacturer = await client.read_char(DeviceInformation.manufacturer_name, None) model = await client.read_char(DeviceInformation.model_number, None) - name = await client.read_char( - DeviceConfiguration.custom_device_name, entry.title - ) - uuids = await client.get_all_characteristics_uuid() - await client.update_timestamp(dt_util.now()) + + name = entry.title + name = await client.read_char(DeviceConfiguration.custom_device_name, name) + name = await client.read_char(AquaContour.custom_device_name, name) + + await _update_timestamp(client, DeviceConfiguration.unix_timestamp) + await _update_timestamp(client, AquaContour.unix_timestamp) + except (TimeoutError, CommunicationFailure, DeviceUnavailable) as exception: await client.disconnect() raise ConfigEntryNotReady( @@ -83,7 +112,7 @@ async def async_setup_entry( ) coordinator = GardenaBluetoothCoordinator( - hass, entry, LOGGER, client, uuids, device, address + hass, entry, LOGGER, client, set(chars.keys()), device, address ) entry.runtime_data = coordinator diff --git a/homeassistant/components/gardena_bluetooth/binary_sensor.py b/homeassistant/components/gardena_bluetooth/binary_sensor.py index b41988afd8c286..4fddd1a53b1de2 100644 --- a/homeassistant/components/gardena_bluetooth/binary_sensor.py +++ b/homeassistant/components/gardena_bluetooth/binary_sensor.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field -from gardena_bluetooth.const import Sensor, Valve +from gardena_bluetooth.const import AquaContour, Sensor, Valve from gardena_bluetooth.parse import CharacteristicBool from homeassistant.components.binary_sensor import ( @@ -34,19 +34,26 @@ def context(self) -> set[str]: DESCRIPTIONS = ( GardenaBluetoothBinarySensorEntityDescription( - key=Valve.connected_state.uuid, + key=Valve.connected_state.unique_id, translation_key="valve_connected_state", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, char=Valve.connected_state, ), GardenaBluetoothBinarySensorEntityDescription( - key=Sensor.connected_state.uuid, + key=Sensor.connected_state.unique_id, translation_key="sensor_connected_state", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, char=Sensor.connected_state, ), + GardenaBluetoothBinarySensorEntityDescription( + key=AquaContour.frost_warning.unique_id, + translation_key="frost_warning", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + char=AquaContour.frost_warning, + ), ) @@ -60,7 +67,7 @@ async def async_setup_entry( entities = [ GardenaBluetoothBinarySensor(coordinator, description, description.context) for description in DESCRIPTIONS - if description.key in coordinator.characteristics + if description.char.unique_id in coordinator.characteristics ] async_add_entities(entities) diff --git a/homeassistant/components/gardena_bluetooth/button.py b/homeassistant/components/gardena_bluetooth/button.py index 6a4f0395fe0adb..1dda3717487700 100644 --- a/homeassistant/components/gardena_bluetooth/button.py +++ b/homeassistant/components/gardena_bluetooth/button.py @@ -30,7 +30,7 @@ def context(self) -> set[str]: DESCRIPTIONS = ( GardenaBluetoothButtonEntityDescription( - key=Reset.factory_reset.uuid, + key=Reset.factory_reset.unique_id, translation_key="factory_reset", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -49,7 +49,7 @@ async def async_setup_entry( entities = [ GardenaBluetoothButton(coordinator, description, description.context) for description in DESCRIPTIONS - if description.key in coordinator.characteristics + if description.char.unique_id in coordinator.characteristics ] async_add_entities(entities) diff --git a/homeassistant/components/gardena_bluetooth/config_flow.py b/homeassistant/components/gardena_bluetooth/config_flow.py index 47db758c7897a7..329d8a8fb3be7a 100644 --- a/homeassistant/components/gardena_bluetooth/config_flow.py +++ b/homeassistant/components/gardena_bluetooth/config_flow.py @@ -43,6 +43,7 @@ def _is_supported(discovery_info: BluetoothServiceInfo): ProductType.WATER_COMPUTER, ProductType.AUTOMATS, ProductType.PRESSURE_TANKS, + ProductType.AQUA_CONTOURS, ): _LOGGER.debug("Unsupported device: %s", manufacturer_data) return False @@ -70,6 +71,7 @@ def __init__(self) -> None: async def async_read_data(self): """Try to connect to device and extract information.""" + assert self.address client = Client(get_connection(self.hass, self.address)) try: model = await client.read_char(DeviceInformation.model_number) diff --git a/homeassistant/components/gardena_bluetooth/manifest.json b/homeassistant/components/gardena_bluetooth/manifest.json index b3d0bd8257a5e8..966a10bc9b0305 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==1.6.0"] + "requirements": ["gardena-bluetooth==2.1.0"] } diff --git a/homeassistant/components/gardena_bluetooth/number.py b/homeassistant/components/gardena_bluetooth/number.py index 342061c18d136a..03c342f7478942 100644 --- a/homeassistant/components/gardena_bluetooth/number.py +++ b/homeassistant/components/gardena_bluetooth/number.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field -from gardena_bluetooth.const import DeviceConfiguration, Sensor, Valve +from gardena_bluetooth.const import DeviceConfiguration, Sensor, Spray, Valve from gardena_bluetooth.parse import ( Characteristic, CharacteristicInt, @@ -18,7 +18,7 @@ NumberEntityDescription, NumberMode, ) -from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime +from homeassistant.const import DEGREE, PERCENTAGE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -34,6 +34,7 @@ class GardenaBluetoothNumberEntityDescription(NumberEntityDescription): default_factory=lambda: CharacteristicInt("") ) connected_state: Characteristic | None = None + scale: float = 1.0 @property def context(self) -> set[str]: @@ -46,7 +47,7 @@ def context(self) -> set[str]: DESCRIPTIONS = ( GardenaBluetoothNumberEntityDescription( - key=Valve.manual_watering_time.uuid, + key=Valve.manual_watering_time.unique_id, translation_key="manual_watering_time", native_unit_of_measurement=UnitOfTime.SECONDS, mode=NumberMode.BOX, @@ -58,7 +59,7 @@ def context(self) -> set[str]: device_class=NumberDeviceClass.DURATION, ), GardenaBluetoothNumberEntityDescription( - key=Valve.remaining_open_time.uuid, + key=Valve.remaining_open_time.unique_id, translation_key="remaining_open_time", native_unit_of_measurement=UnitOfTime.SECONDS, native_min_value=0.0, @@ -69,7 +70,7 @@ def context(self) -> set[str]: device_class=NumberDeviceClass.DURATION, ), GardenaBluetoothNumberEntityDescription( - key=DeviceConfiguration.rain_pause.uuid, + key=DeviceConfiguration.rain_pause.unique_id, translation_key="rain_pause", native_unit_of_measurement=UnitOfTime.MINUTES, mode=NumberMode.BOX, @@ -81,7 +82,7 @@ def context(self) -> set[str]: device_class=NumberDeviceClass.DURATION, ), GardenaBluetoothNumberEntityDescription( - key=DeviceConfiguration.seasonal_adjust.uuid, + key=DeviceConfiguration.seasonal_adjust.unique_id, translation_key="seasonal_adjust", native_unit_of_measurement=UnitOfTime.DAYS, mode=NumberMode.BOX, @@ -93,7 +94,7 @@ def context(self) -> set[str]: device_class=NumberDeviceClass.DURATION, ), GardenaBluetoothNumberEntityDescription( - key=Sensor.threshold.uuid, + key=Sensor.threshold.unique_id, translation_key="sensor_threshold", native_unit_of_measurement=PERCENTAGE, mode=NumberMode.BOX, @@ -104,6 +105,27 @@ def context(self) -> set[str]: char=Sensor.threshold, connected_state=Sensor.connected_state, ), + GardenaBluetoothNumberEntityDescription( + key="spray_sector", + translation_key="spray_sector", + native_unit_of_measurement=DEGREE, + mode=NumberMode.BOX, + native_min_value=0.0, + native_max_value=359.0, + native_step=1.0, + char=Spray.sector, + ), + GardenaBluetoothNumberEntityDescription( + key="spray_distance", + translation_key="spray_distance", + native_unit_of_measurement=PERCENTAGE, + mode=NumberMode.SLIDER, + native_min_value=0.0, + native_max_value=100.0, + native_step=0.1, + char=Spray.distance, + scale=10.0, + ), ) @@ -117,9 +139,9 @@ async def async_setup_entry( entities: list[NumberEntity] = [ GardenaBluetoothNumber(coordinator, description, description.context) for description in DESCRIPTIONS - if description.key in coordinator.characteristics + if description.char.unique_id in coordinator.characteristics ] - if Valve.remaining_open_time.uuid in coordinator.characteristics: + if Valve.remaining_open_time.unique_id in coordinator.characteristics: entities.append(GardenaBluetoothRemainingOpenSetNumber(coordinator)) async_add_entities(entities) @@ -134,7 +156,7 @@ def _handle_coordinator_update(self) -> None: if data is None: self._attr_native_value = None else: - self._attr_native_value = float(data) + self._attr_native_value = float(data) / self.entity_description.scale if char := self.entity_description.connected_state: self._attr_available = bool(self.coordinator.get_cached(char)) @@ -145,7 +167,9 @@ def _handle_coordinator_update(self) -> None: async def async_set_native_value(self, value: float) -> None: """Set new value.""" - await self.coordinator.write(self.entity_description.char, int(value)) + await self.coordinator.write( + self.entity_description.char, int(value * self.entity_description.scale) + ) self.async_write_ha_state() diff --git a/homeassistant/components/gardena_bluetooth/select.py b/homeassistant/components/gardena_bluetooth/select.py new file mode 100644 index 00000000000000..931517e3e4dfa6 --- /dev/null +++ b/homeassistant/components/gardena_bluetooth/select.py @@ -0,0 +1,113 @@ +"""Support for select entities.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import IntEnum + +from gardena_bluetooth.const import ( + AquaContour, + AquaContourPosition, + AquaContourWatering, +) +from gardena_bluetooth.parse import CharacteristicInt + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import GardenaBluetoothConfigEntry +from .entity import GardenaBluetoothDescriptorEntity + + +def _enum_to_int(enum: type[IntEnum]) -> dict[str, int]: + return {member.name.lower(): member.value for member in enum} + + +def _reverse_dict(value: dict[str, int]) -> dict[int, str]: + return {value: key for key, value in value.items()} + + +@dataclass(frozen=True, kw_only=True) +class GardenaBluetoothSelectEntityDescription(SelectEntityDescription): + """Description of entity.""" + + key: str = field(init=False) + char: CharacteristicInt + option_to_number: dict[str, int] + number_to_option: dict[int, str] = field(init=False) + + def __post_init__(self): + """Initialize calculated fields.""" + object.__setattr__(self, "key", self.char.unique_id) + object.__setattr__(self, "options", list(self.option_to_number.keys())) + object.__setattr__( + self, "number_to_option", _reverse_dict(self.option_to_number) + ) + + @property + def context(self) -> set[str]: + """Context needed for update coordinator.""" + return {self.char.uuid} + + +DESCRIPTIONS = ( + GardenaBluetoothSelectEntityDescription( + translation_key="watering_active", + char=AquaContourWatering.watering_active, + option_to_number=_enum_to_int(AquaContourWatering.watering_active.enum), + ), + GardenaBluetoothSelectEntityDescription( + translation_key="operation_mode", + char=AquaContour.operation_mode, + option_to_number=_enum_to_int(AquaContour.operation_mode.enum), + ), + GardenaBluetoothSelectEntityDescription( + translation_key="active_position", + char=AquaContourPosition.active_position, + option_to_number={ + "position_1": 1, + "position_2": 2, + "position_3": 3, + "position_4": 4, + "position_5": 5, + }, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: GardenaBluetoothConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up select based on a config entry.""" + coordinator = entry.runtime_data + entities = [ + GardenaBluetoothSelectEntity(coordinator, description, description.context) + for description in DESCRIPTIONS + if description.char.unique_id in coordinator.characteristics + ] + async_add_entities(entities) + + +class GardenaBluetoothSelectEntity(GardenaBluetoothDescriptorEntity, SelectEntity): + """Representation of a select entity.""" + + entity_description: GardenaBluetoothSelectEntityDescription + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + char = self.entity_description.char + value = self.coordinator.get_cached(char) + if value is None: + return None + return self.entity_description.number_to_option.get(value) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + char = self.entity_description.char + value = self.entity_description.option_to_number[option] + await self.coordinator.write(char, value) + self.async_write_ha_state() diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py index 602f5bdfd6e013..d31a00f73da331 100644 --- a/homeassistant/components/gardena_bluetooth/sensor.py +++ b/homeassistant/components/gardena_bluetooth/sensor.py @@ -2,10 +2,19 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta -from gardena_bluetooth.const import Battery, Sensor, Valve +from gardena_bluetooth.const import ( + AquaContourBattery, + Battery, + EventHistory, + FlowStatistics, + Sensor, + Spray, + Valve, +) from gardena_bluetooth.parse import Characteristic from homeassistant.components.sensor import ( @@ -13,8 +22,15 @@ SensorEntity, SensorEntityDescription, SensorStateClass, + StateType, +) +from homeassistant.const import ( + DEGREE, + PERCENTAGE, + EntityCategory, + UnitOfVolume, + UnitOfVolumeFlowRate, ) -from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util @@ -22,13 +38,28 @@ from .coordinator import GardenaBluetoothConfigEntry, GardenaBluetoothCoordinator from .entity import GardenaBluetoothDescriptorEntity, GardenaBluetoothEntity +type SensorRawType = StateType | datetime + + +def _get_timestamp(value: datetime | None): + if value is None: + return None + return value.replace(tzinfo=dt_util.get_default_time_zone()) + + +def _get_distance_ratio(value: int | None): + if value is None: + return None + return value / 1000 + @dataclass(frozen=True) -class GardenaBluetoothSensorEntityDescription(SensorEntityDescription): +class GardenaBluetoothSensorEntityDescription[T](SensorEntityDescription): """Description of entity.""" - char: Characteristic = field(default_factory=lambda: Characteristic("")) + char: Characteristic[T] = field(default_factory=lambda: Characteristic("")) connected_state: Characteristic | None = None + get: Callable[[T | None], SensorRawType] = lambda x: x # type: ignore[assignment, return-value] @property def context(self) -> set[str]: @@ -41,7 +72,7 @@ def context(self) -> set[str]: DESCRIPTIONS = ( GardenaBluetoothSensorEntityDescription( - key=Valve.activation_reason.uuid, + key=Valve.activation_reason.unique_id, translation_key="activation_reason", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -49,7 +80,7 @@ def context(self) -> set[str]: char=Valve.activation_reason, ), GardenaBluetoothSensorEntityDescription( - key=Battery.battery_level.uuid, + key=Battery.battery_level.unique_id, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, @@ -57,7 +88,15 @@ def context(self) -> set[str]: char=Battery.battery_level, ), GardenaBluetoothSensorEntityDescription( - key=Sensor.battery_level.uuid, + key=AquaContourBattery.battery_level.unique_id, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + char=AquaContourBattery.battery_level, + ), + GardenaBluetoothSensorEntityDescription( + key=Sensor.battery_level.unique_id, translation_key="sensor_battery_level", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.BATTERY, @@ -67,7 +106,7 @@ def context(self) -> set[str]: connected_state=Sensor.connected_state, ), GardenaBluetoothSensorEntityDescription( - key=Sensor.value.uuid, + key=Sensor.value.unique_id, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.MOISTURE, native_unit_of_measurement=PERCENTAGE, @@ -75,19 +114,91 @@ def context(self) -> set[str]: connected_state=Sensor.connected_state, ), GardenaBluetoothSensorEntityDescription( - key=Sensor.type.uuid, + key=Sensor.type.unique_id, translation_key="sensor_type", entity_category=EntityCategory.DIAGNOSTIC, char=Sensor.type, connected_state=Sensor.connected_state, ), GardenaBluetoothSensorEntityDescription( - key=Sensor.measurement_timestamp.uuid, + key=Sensor.measurement_timestamp.unique_id, translation_key="sensor_measurement_timestamp", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, char=Sensor.measurement_timestamp, connected_state=Sensor.connected_state, + get=_get_timestamp, + ), + GardenaBluetoothSensorEntityDescription( + key=FlowStatistics.overall.unique_id, + translation_key="flow_statistics_overall", + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.VOLUME, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfVolume.LITERS, + char=FlowStatistics.overall, + ), + GardenaBluetoothSensorEntityDescription( + key=FlowStatistics.current.unique_id, + translation_key="flow_statistics_current", + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + char=FlowStatistics.current, + ), + GardenaBluetoothSensorEntityDescription( + key=FlowStatistics.resettable.unique_id, + translation_key="flow_statistics_resettable", + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.VOLUME, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfVolume.LITERS, + char=FlowStatistics.resettable, + ), + GardenaBluetoothSensorEntityDescription( + key=FlowStatistics.last_reset.unique_id, + translation_key="flow_statistics_reset_timestamp", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + char=FlowStatistics.last_reset, + get=_get_timestamp, + ), + GardenaBluetoothSensorEntityDescription( + key=Spray.current_distance.unique_id, + translation_key="spray_current_distance", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + char=Spray.current_distance, + get=_get_distance_ratio, + ), + GardenaBluetoothSensorEntityDescription( + key=Spray.current_sector.unique_id, + translation_key="spray_current_sector", + state_class=SensorStateClass.MEASUREMENT_ANGLE, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=DEGREE, + char=Spray.current_sector, + ), + GardenaBluetoothSensorEntityDescription( + key="aqua_contour_error", + translation_key="aqua_contour_error", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + char=EventHistory.error, + get=lambda x: ( + x.error_code.name.lower() + if x and isinstance(x.error_code, EventHistory.error.enum) + else None + ), + options=[member.name.lower() for member in EventHistory.error.enum], + ), + GardenaBluetoothSensorEntityDescription( + key="aqua_contour_error_timestamp", + translation_key="error_timestamp", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.TIMESTAMP, + char=EventHistory.error, + get=lambda x: _get_timestamp(x.time_stamp) if x else None, ), ) @@ -102,9 +213,9 @@ async def async_setup_entry( entities: list[GardenaBluetoothEntity] = [ GardenaBluetoothSensor(coordinator, description, description.context) for description in DESCRIPTIONS - if description.key in coordinator.characteristics + if description.char.unique_id in coordinator.characteristics ] - if Valve.remaining_open_time.uuid in coordinator.characteristics: + if Valve.remaining_open_time.unique_id in coordinator.characteristics: entities.append(GardenaBluetoothRemainSensor(coordinator)) async_add_entities(entities) @@ -116,8 +227,7 @@ class GardenaBluetoothSensor(GardenaBluetoothDescriptorEntity, SensorEntity): def _handle_coordinator_update(self) -> None: value = self.coordinator.get_cached(self.entity_description.char) - if isinstance(value, datetime): - value = value.replace(tzinfo=dt_util.get_default_time_zone()) + value = self.entity_description.get(value) self._attr_native_value = value if char := self.entity_description.connected_state: diff --git a/homeassistant/components/gardena_bluetooth/strings.json b/homeassistant/components/gardena_bluetooth/strings.json index 0926d4dd645186..8c8815631eb5cf 100644 --- a/homeassistant/components/gardena_bluetooth/strings.json +++ b/homeassistant/components/gardena_bluetooth/strings.json @@ -22,6 +22,9 @@ }, "entity": { "binary_sensor": { + "frost_warning": { + "name": "Frost" + }, "sensor_connected_state": { "name": "Sensor connection" }, @@ -52,12 +55,79 @@ }, "sensor_threshold": { "name": "Sensor threshold" + }, + "spray_distance": { + "name": "Distance" + }, + "spray_sector": { + "name": "Sector" + } + }, + "select": { + "active_position": { + "name": "Active position", + "state": { + "position_1": "Position 1", + "position_2": "Position 2", + "position_3": "Position 3", + "position_4": "Position 4", + "position_5": "Position 5" + } + }, + "operation_mode": { + "name": "Operation mode", + "state": { + "active": "Active", + "deep_sleep": "Deep sleep", + "manual_mode": "Manual", + "pre_winter": "Winter preparation" + } + }, + "watering_active": { + "name": "Watering", + "state": { + "contour_1": "Contour 1", + "contour_2": "Contour 2", + "contour_3": "Contour 3", + "contour_4": "Contour 4", + "contour_5": "Contour 5", + "preview": "Preview", + "rest": "Idle", + "setup_mode": "Setup" + } } }, "sensor": { "activation_reason": { "name": "Activation reason" }, + "aqua_contour_error": { + "name": "Error", + "state": { + "charger_error": "Charger error", + "flash_error": "Flash error", + "no_error": "No error detected", + "no_water": "Not enough water", + "rotation_sensor_error": "Rotation sensor error", + "sprinkler_motor_error": "Sprinkler motor error", + "valve_motor_error": "Valve motor error" + } + }, + "error_timestamp": { + "name": "Error timestamp" + }, + "flow_statistics_current": { + "name": "Current flow" + }, + "flow_statistics_overall": { + "name": "Overall flow" + }, + "flow_statistics_reset_timestamp": { + "name": "Flow reset timestamp" + }, + "flow_statistics_resettable": { + "name": "Flow since reset" + }, "remaining_open_timestamp": { "name": "Valve closing" }, @@ -69,6 +139,12 @@ }, "sensor_type": { "name": "Sensor type" + }, + "spray_current_distance": { + "name": "Current distance" + }, + "spray_current_sector": { + "name": "Current sector" } }, "switch": { diff --git a/homeassistant/components/gardena_bluetooth/switch.py b/homeassistant/components/gardena_bluetooth/switch.py index de1fbe22470150..053a90aaa4de89 100644 --- a/homeassistant/components/gardena_bluetooth/switch.py +++ b/homeassistant/components/gardena_bluetooth/switch.py @@ -35,9 +35,9 @@ class GardenaBluetoothValveSwitch(GardenaBluetoothEntity, SwitchEntity): """Representation of a valve switch.""" characteristics = { - Valve.state.uuid, - Valve.manual_watering_time.uuid, - Valve.remaining_open_time.uuid, + Valve.state.unique_id, + Valve.manual_watering_time.unique_id, + Valve.remaining_open_time.unique_id, } def __init__( @@ -48,7 +48,7 @@ def __init__( super().__init__( coordinator, {Valve.state.uuid, Valve.manual_watering_time.uuid} ) - self._attr_unique_id = f"{coordinator.address}-{Valve.state.uuid}" + self._attr_unique_id = f"{coordinator.address}-{Valve.state.unique_id}" self._attr_translation_key = "state" self._attr_is_on = None self._attr_entity_registry_enabled_default = False diff --git a/homeassistant/components/gardena_bluetooth/util.py b/homeassistant/components/gardena_bluetooth/util.py new file mode 100644 index 00000000000000..ce2d862c600d19 --- /dev/null +++ b/homeassistant/components/gardena_bluetooth/util.py @@ -0,0 +1,51 @@ +"""Utility functions for Gardena Bluetooth integration.""" + +import asyncio +from collections.abc import AsyncIterator + +from gardena_bluetooth.parse import ManufacturerData, ProductType + +from homeassistant.components import bluetooth + + +async def _async_service_info( + hass, address +) -> AsyncIterator[bluetooth.BluetoothServiceInfoBleak]: + queue = asyncio.Queue[bluetooth.BluetoothServiceInfoBleak]() + + def _callback( + service_info: bluetooth.BluetoothServiceInfoBleak, + change: bluetooth.BluetoothChange, + ) -> None: + if change != bluetooth.BluetoothChange.ADVERTISEMENT: + return + + queue.put_nowait(service_info) + + service_info = bluetooth.async_last_service_info(hass, address, True) + if service_info: + yield service_info + + cancel = bluetooth.async_register_callback( + hass, + _callback, + {bluetooth.match.ADDRESS: address}, + bluetooth.BluetoothScanningMode.ACTIVE, + ) + try: + while True: + yield await queue.get() + finally: + cancel() + + +async def async_get_product_type(hass, address: str) -> ProductType: + """Wait for enough packets of manufacturer data to get the product type.""" + data = ManufacturerData() + + async for service_info in _async_service_info(hass, address): + data.update(service_info.manufacturer_data.get(ManufacturerData.company, b"")) + product_type = ProductType.from_manufacturer_data(data) + if product_type is not ProductType.UNKNOWN: + return product_type + raise AssertionError("Iterator should have been infinite") diff --git a/homeassistant/components/gardena_bluetooth/valve.py b/homeassistant/components/gardena_bluetooth/valve.py index 247a85f93f12f7..a5fa27962449b6 100644 --- a/homeassistant/components/gardena_bluetooth/valve.py +++ b/homeassistant/components/gardena_bluetooth/valve.py @@ -44,9 +44,9 @@ class GardenaBluetoothValve(GardenaBluetoothEntity, ValveEntity): _attr_device_class = ValveDeviceClass.WATER characteristics = { - Valve.state.uuid, - Valve.manual_watering_time.uuid, - Valve.remaining_open_time.uuid, + Valve.state.unique_id, + Valve.manual_watering_time.unique_id, + Valve.remaining_open_time.unique_id, } def __init__( @@ -57,7 +57,7 @@ def __init__( super().__init__( coordinator, {Valve.state.uuid, Valve.manual_watering_time.uuid} ) - self._attr_unique_id = f"{coordinator.address}-{Valve.state.uuid}" + self._attr_unique_id = f"{coordinator.address}-{Valve.state.unique_id}" def _handle_coordinator_update(self) -> None: self._attr_is_closed = not self.coordinator.get_cached(Valve.state) diff --git a/homeassistant/components/gate/__init__.py b/homeassistant/components/gate/__init__.py new file mode 100644 index 00000000000000..b1fa802e45c50c --- /dev/null +++ b/homeassistant/components/gate/__init__.py @@ -0,0 +1,17 @@ +"""Integration for gate triggers.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +DOMAIN = "gate" +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/gate/condition.py b/homeassistant/components/gate/condition.py new file mode 100644 index 00000000000000..8ec7234d4205ac --- /dev/null +++ b/homeassistant/components/gate/condition.py @@ -0,0 +1,24 @@ +"""Provides conditions for gates.""" + +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + CoverDeviceClass, + make_cover_is_closed_condition, + make_cover_is_open_condition, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.condition import Condition + +DEVICE_CLASSES_GATE: dict[str, str] = { + COVER_DOMAIN: CoverDeviceClass.GATE, +} + +CONDITIONS: dict[str, type[Condition]] = { + "is_closed": make_cover_is_closed_condition(device_classes=DEVICE_CLASSES_GATE), + "is_open": make_cover_is_open_condition(device_classes=DEVICE_CLASSES_GATE), +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the conditions for gates.""" + return CONDITIONS diff --git a/homeassistant/components/gate/conditions.yaml b/homeassistant/components/gate/conditions.yaml new file mode 100644 index 00000000000000..aea805c2069f35 --- /dev/null +++ b/homeassistant/components/gate/conditions.yaml @@ -0,0 +1,24 @@ +.condition_common_fields: &condition_common_fields + behavior: + required: true + default: any + selector: + select: + translation_key: condition_behavior + options: + - all + - any + +is_closed: + fields: *condition_common_fields + target: + entity: + - domain: cover + device_class: gate + +is_open: + fields: *condition_common_fields + target: + entity: + - domain: cover + device_class: gate diff --git a/homeassistant/components/gate/icons.json b/homeassistant/components/gate/icons.json new file mode 100644 index 00000000000000..7db8f448e6505e --- /dev/null +++ b/homeassistant/components/gate/icons.json @@ -0,0 +1,18 @@ +{ + "conditions": { + "is_closed": { + "condition": "mdi:gate" + }, + "is_open": { + "condition": "mdi:gate-open" + } + }, + "triggers": { + "closed": { + "trigger": "mdi:gate" + }, + "opened": { + "trigger": "mdi:gate-open" + } + } +} diff --git a/homeassistant/components/gate/manifest.json b/homeassistant/components/gate/manifest.json new file mode 100644 index 00000000000000..d20b1e238241bf --- /dev/null +++ b/homeassistant/components/gate/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "gate", + "name": "Gate", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/gate", + "integration_type": "system", + "quality_scale": "internal" +} diff --git a/homeassistant/components/gate/strings.json b/homeassistant/components/gate/strings.json new file mode 100644 index 00000000000000..134e9bb108f794 --- /dev/null +++ b/homeassistant/components/gate/strings.json @@ -0,0 +1,68 @@ +{ + "common": { + "condition_behavior_description": "How the state should match on the targeted gates.", + "condition_behavior_name": "Behavior", + "trigger_behavior_description": "The behavior of the targeted gates to trigger on.", + "trigger_behavior_name": "Behavior" + }, + "conditions": { + "is_closed": { + "description": "Tests if one or more gates are closed.", + "fields": { + "behavior": { + "description": "[%key:component::gate::common::condition_behavior_description%]", + "name": "[%key:component::gate::common::condition_behavior_name%]" + } + }, + "name": "Gate is closed" + }, + "is_open": { + "description": "Tests if one or more gates are open.", + "fields": { + "behavior": { + "description": "[%key:component::gate::common::condition_behavior_description%]", + "name": "[%key:component::gate::common::condition_behavior_name%]" + } + }, + "name": "Gate is open" + } + }, + "selector": { + "condition_behavior": { + "options": { + "all": "All", + "any": "Any" + } + }, + "trigger_behavior": { + "options": { + "any": "Any", + "first": "First", + "last": "Last" + } + } + }, + "title": "Gate", + "triggers": { + "closed": { + "description": "Triggers after one or more gates close.", + "fields": { + "behavior": { + "description": "[%key:component::gate::common::trigger_behavior_description%]", + "name": "[%key:component::gate::common::trigger_behavior_name%]" + } + }, + "name": "Gate closed" + }, + "opened": { + "description": "Triggers after one or more gates open.", + "fields": { + "behavior": { + "description": "[%key:component::gate::common::trigger_behavior_description%]", + "name": "[%key:component::gate::common::trigger_behavior_name%]" + } + }, + "name": "Gate opened" + } + } +} diff --git a/homeassistant/components/gate/trigger.py b/homeassistant/components/gate/trigger.py new file mode 100644 index 00000000000000..4f8d6ffa53cc51 --- /dev/null +++ b/homeassistant/components/gate/trigger.py @@ -0,0 +1,25 @@ +"""Provides triggers for gates.""" + +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + CoverDeviceClass, + make_cover_closed_trigger, + make_cover_opened_trigger, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.trigger import Trigger + +DEVICE_CLASSES_GATE: dict[str, str] = { + COVER_DOMAIN: CoverDeviceClass.GATE, +} + + +TRIGGERS: dict[str, type[Trigger]] = { + "opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_GATE), + "closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_GATE), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for gates.""" + return TRIGGERS diff --git a/homeassistant/components/binary_sensor/triggers.yaml b/homeassistant/components/gate/triggers.yaml similarity index 69% rename from homeassistant/components/binary_sensor/triggers.yaml rename to homeassistant/components/gate/triggers.yaml index 3cd4031af44e50..b50ae440c36915 100644 --- a/homeassistant/components/binary_sensor/triggers.yaml +++ b/homeassistant/components/gate/triggers.yaml @@ -10,16 +10,16 @@ - last - any -occupancy_cleared: +closed: fields: *trigger_common_fields target: entity: - domain: binary_sensor - device_class: occupancy + - domain: cover + device_class: gate -occupancy_detected: +opened: fields: *trigger_common_fields target: entity: - domain: binary_sensor - device_class: occupancy + - domain: cover + device_class: gate diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index 33e1afeb18f792..b6d354b6f605d3 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/generic", "integration_type": "device", "iot_class": "local_push", - "requirements": ["av==16.0.1", "Pillow==12.0.0"] + "requirements": ["av==16.0.1", "Pillow==12.1.1"] } diff --git a/homeassistant/components/generic_thermostat/__init__.py b/homeassistant/components/generic_thermostat/__init__.py index 6927b9fe26e5b0..991bc0d29035b7 100644 --- a/homeassistant/components/generic_thermostat/__init__.py +++ b/homeassistant/components/generic_thermostat/__init__.py @@ -12,7 +12,7 @@ async_remove_helper_config_entry_from_source_device, ) -from .const import CONF_HEATER, CONF_SENSOR, PLATFORMS +from .const import CONF_DUR_COOLDOWN, CONF_HEATER, CONF_MIN_DUR, CONF_SENSOR, PLATFORMS _LOGGER = logging.getLogger(__name__) @@ -91,8 +91,13 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> helper_config_entry_id=config_entry.entry_id, source_device_id=source_device_id, ) + if config_entry.minor_version < 3: + # Set `cycle_cooldown` to `min_cycle_duration` to mimic the old behavior + if CONF_MIN_DUR in options: + options[CONF_DUR_COOLDOWN] = options[CONF_MIN_DUR] + hass.config_entries.async_update_entry( - config_entry, options=options, minor_version=2 + config_entry, options=options, minor_version=3 ) _LOGGER.debug( diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index 26a368bcd6693c..10b24ec17cab46 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -5,6 +5,7 @@ import asyncio from collections.abc import Mapping from datetime import datetime, timedelta +from functools import partial import logging import math from typing import Any @@ -38,7 +39,9 @@ UnitOfTemperature, ) from homeassistant.core import ( + CALLBACK_TYPE, DOMAIN as HOMEASSISTANT_DOMAIN, + Context, CoreState, Event, EventStateChangedData, @@ -46,27 +49,30 @@ State, callback, ) -from homeassistant.exceptions import ConditionError -from homeassistant.helpers import condition, config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) from homeassistant.helpers.event import ( + async_call_later, async_track_state_change_event, async_track_time_interval, ) from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolDictType +from homeassistant.util import dt as dt_util from .const import ( CONF_AC_MODE, CONF_COLD_TOLERANCE, + CONF_DUR_COOLDOWN, CONF_HEATER, CONF_HOT_TOLERANCE, CONF_KEEP_ALIVE, + CONF_MAX_DUR, CONF_MAX_TEMP, CONF_MIN_DUR, CONF_MIN_TEMP, @@ -98,6 +104,8 @@ vol.Optional(CONF_AC_MODE): cv.boolean, vol.Optional(CONF_MAX_TEMP): vol.Coerce(float), vol.Optional(CONF_MIN_DUR): cv.positive_time_period, + vol.Optional(CONF_MAX_DUR): cv.positive_time_period, + vol.Optional(CONF_DUR_COOLDOWN): cv.positive_time_period, vol.Optional(CONF_MIN_TEMP): vol.Coerce(float), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_COLD_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(float), @@ -167,6 +175,8 @@ async def _async_setup_config( target_temp: float | None = config.get(CONF_TARGET_TEMP) ac_mode: bool | None = config.get(CONF_AC_MODE) min_cycle_duration: timedelta | None = config.get(CONF_MIN_DUR) + max_cycle_duration: timedelta | None = config.get(CONF_MAX_DUR) + cycle_cooldown: timedelta | None = config.get(CONF_DUR_COOLDOWN) cold_tolerance: float = config[CONF_COLD_TOLERANCE] hot_tolerance: float = config[CONF_HOT_TOLERANCE] keep_alive: timedelta | None = config.get(CONF_KEEP_ALIVE) @@ -190,6 +200,8 @@ async def _async_setup_config( target_temp=target_temp, ac_mode=ac_mode, min_cycle_duration=min_cycle_duration, + max_cycle_duration=max_cycle_duration, + cycle_cooldown=cycle_cooldown, cold_tolerance=cold_tolerance, hot_tolerance=hot_tolerance, keep_alive=keep_alive, @@ -221,6 +233,8 @@ def __init__( target_temp: float | None, ac_mode: bool | None, min_cycle_duration: timedelta | None, + max_cycle_duration: timedelta | None, + cycle_cooldown: timedelta | None, cold_tolerance: float, hot_tolerance: float, keep_alive: timedelta | None, @@ -240,8 +254,16 @@ def __init__( heater_entity_id, ) self.ac_mode = ac_mode - self.min_cycle_duration = min_cycle_duration + self.min_cycle_duration = min_cycle_duration or timedelta() + self.max_cycle_duration = max_cycle_duration + self.cycle_cooldown = cycle_cooldown or timedelta() self._cold_tolerance = cold_tolerance + # Subtract the cooldown so it doesn't impact startup + self._last_toggled_time = dt_util.utcnow() - self.cycle_cooldown + self._cycle_callback: CALLBACK_TYPE | None = None + self._check_callback: CALLBACK_TYPE | None = None + # Context ID used to detect our own toggles + self._last_context_id: str | None = None self._hot_tolerance = hot_tolerance self._keep_alive = keep_alive self._hvac_mode = initial_hvac_mode @@ -289,6 +311,7 @@ async def async_added_to_hass(self) -> None: self.hass, [self.heater_entity_id], self._async_switch_changed ) ) + self.async_on_remove(self._cancel_timers) if self._keep_alive: self.async_on_remove( @@ -482,6 +505,18 @@ def _async_switch_changed(self, event: Event[EventStateChangedData]) -> None: self.hass.async_create_task( self._check_switch_initial_state(), eager_start=True ) + + # Update timestamp on toggle + self._last_toggled_time = new_state.last_changed + + # If the user toggles the switch, assume they want control and clear the timers. + # Note: If a manual interaction occurs within the 2s context window of a switch + # toggle initiated by us, we may not detect manual control. Users are advised to + # use the climate entity for reliable control, not the switch entity. + if new_state.context.id != self._last_context_id: + _LOGGER.debug("External switch change detected, clearing timers") + self._last_context_id = None + self._cancel_timers() self.async_write_ha_state() @callback @@ -517,57 +552,69 @@ async def _async_control_heating( if not self._active or self._hvac_mode == HVACMode.OFF: return - # If the `force` argument is True, we - # ignore `min_cycle_duration`. - # If the `time` argument is not none, we were invoked for - # keep-alive purposes, and `min_cycle_duration` is irrelevant. - if not force and time is None and self.min_cycle_duration: - if self._is_device_active: - current_state = STATE_ON - else: - current_state = HVACMode.OFF - try: - long_enough = condition.state( - self.hass, - self.heater_entity_id, - current_state, - self.min_cycle_duration, - ) - except ConditionError: - long_enough = False - - if not long_enough: - return + if force and time is not None 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", + self.heater_entity_id, + self.max_cycle_duration, + ) + self._cancel_cycle_timer() + await self._async_heater_turn_off() + return assert self._cur_temp is not None and self._target_temp is not None - - min_temp = self._target_temp - self._cold_tolerance - max_temp = self._target_temp + self._hot_tolerance + too_cold = self._target_temp > self._cur_temp + self._cold_tolerance + too_hot = self._target_temp < self._cur_temp - self._hot_tolerance + now = dt_util.utcnow() if self._is_device_active: - if (self.ac_mode and self._cur_temp <= min_temp) or ( - not self.ac_mode and self._cur_temp >= max_temp - ): - _LOGGER.debug("Turning off heater %s", self.heater_entity_id) - await self._async_heater_turn_off() + if (self.ac_mode and too_cold) or (not self.ac_mode and too_hot): + # Make sure it's past the `min_cycle_duration` before turning off + if ( + self._last_toggled_time + self.min_cycle_duration <= now + or force + ): + _LOGGER.debug("Turning off heater %s", self.heater_entity_id) + await self._async_heater_turn_off() + elif self._check_callback is None: + _LOGGER.debug( + "Minimum cycle time not reached, check again at %s", + self._last_toggled_time + self.min_cycle_duration, + ) + self._check_callback = async_call_later( + self.hass, + now - self._last_toggled_time + self.min_cycle_duration, + self._async_timer_control_heating, + ) elif time is not None: - # The time argument is passed only in keep-alive case + # This is a keep-alive call, so ensure it's on _LOGGER.debug( - "Keep-alive - Turning on heater heater %s", + "Keep-alive - Turning on heater %s", self.heater_entity_id, ) + await self._async_heater_turn_on(keepalive=True) + elif (self.ac_mode and too_hot) or (not self.ac_mode and too_cold): + # Make sure it's past the `cycle_cooldown` before turning on + if self._last_toggled_time + self.cycle_cooldown <= now or force: + _LOGGER.debug("Turning on heater %s", self.heater_entity_id) await self._async_heater_turn_on() - elif (self.ac_mode and self._cur_temp > max_temp) or ( - not self.ac_mode and self._cur_temp < min_temp - ): - _LOGGER.debug("Turning on heater %s", self.heater_entity_id) - await self._async_heater_turn_on() + elif self._check_callback is None: + _LOGGER.debug( + "Cooldown time not reached, check again at %s", + self._last_toggled_time + self.cycle_cooldown, + ) + self._check_callback = async_call_later( + self.hass, + now - self._last_toggled_time + self.cycle_cooldown, + self._async_timer_control_heating, + ) elif time is not None: - # The time argument is passed only in keep-alive case + # This is a keep-alive call, so ensure it's off _LOGGER.debug( "Keep-alive - Turning off heater %s", self.heater_entity_id ) - await self._async_heater_turn_off() + await self._async_heater_turn_off(keepalive=True) @property def _is_device_active(self) -> bool | None: @@ -577,19 +624,48 @@ def _is_device_active(self) -> bool | None: return self.hass.states.is_state(self.heater_entity_id, STATE_ON) - async def _async_heater_turn_on(self) -> None: + 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) + self._last_context_id = new_context.id await self.hass.services.async_call( - HOMEASSISTANT_DOMAIN, SERVICE_TURN_ON, data, context=self._context + HOMEASSISTANT_DOMAIN, SERVICE_TURN_ON, data, context=new_context ) + if not keepalive: + # Update timestamp on turn on + self._last_toggled_time = dt_util.utcnow() + self._cancel_check_timer() + if self.max_cycle_duration: + _LOGGER.debug( + "Scheduling maximum run-time shut-off for %s", + self._last_toggled_time + self.max_cycle_duration, + ) + self._cancel_cycle_timer() + self._cycle_callback = async_call_later( + self.hass, + self.max_cycle_duration, + partial(self._async_control_heating, force=True), + ) - async def _async_heater_turn_off(self) -> 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) + self._last_context_id = new_context.id await self.hass.services.async_call( - HOMEASSISTANT_DOMAIN, SERVICE_TURN_OFF, data, context=self._context + HOMEASSISTANT_DOMAIN, SERVICE_TURN_OFF, data, context=new_context ) + if not keepalive: + # Update timestamp on turn off + self._last_toggled_time = dt_util.utcnow() + self._cancel_timers() async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" @@ -613,3 +689,30 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: await self._async_control_heating(force=True) self.async_write_ha_state() + + async def _async_timer_control_heating(self, _: datetime | None = None) -> None: + """Reset check timer and control heating.""" + self._check_callback = None + await self._async_control_heating() + + @callback + def _cancel_check_timer(self) -> None: + """Reset check timer.""" + if self._check_callback: + _LOGGER.debug("Cancelling scheduled state check") + self._check_callback() + self._check_callback = None + + @callback + def _cancel_cycle_timer(self) -> None: + """Reset cycle timer.""" + if self._cycle_callback: + _LOGGER.debug("Cancelling scheduled shut-off") + self._cycle_callback() + self._cycle_callback = None + + @callback + def _cancel_timers(self) -> None: + """Reset timers.""" + self._cancel_check_timer() + self._cancel_cycle_timer() diff --git a/homeassistant/components/generic_thermostat/config_flow.py b/homeassistant/components/generic_thermostat/config_flow.py index 88a09013d75d94..5dbccfabe8c815 100644 --- a/homeassistant/components/generic_thermostat/config_flow.py +++ b/homeassistant/components/generic_thermostat/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +from datetime import timedelta from typing import Any, cast import voluptuous as vol @@ -12,16 +13,20 @@ from homeassistant.const import CONF_NAME, DEGREE from homeassistant.helpers import selector from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, SchemaConfigFlowHandler, + SchemaFlowError, SchemaFlowFormStep, ) from .const import ( CONF_AC_MODE, CONF_COLD_TOLERANCE, + CONF_DUR_COOLDOWN, CONF_HEATER, CONF_HOT_TOLERANCE, CONF_KEEP_ALIVE, + CONF_MAX_DUR, CONF_MAX_TEMP, CONF_MIN_DUR, CONF_MIN_TEMP, @@ -63,6 +68,12 @@ vol.Optional(CONF_KEEP_ALIVE): selector.DurationSelector( selector.DurationSelectorConfig(allow_negative=False) ), + vol.Optional(CONF_MAX_DUR): selector.DurationSelector( + selector.DurationSelectorConfig(allow_negative=False) + ), + vol.Optional(CONF_DUR_COOLDOWN): selector.DurationSelector( + selector.DurationSelectorConfig(allow_negative=False) + ), vol.Optional(CONF_MIN_TEMP): selector.NumberSelector( selector.NumberSelectorConfig( mode=selector.NumberSelectorMode.BOX, unit_of_measurement=DEGREE, step=0.1 @@ -90,13 +101,31 @@ } +async def _validate_config( + handler: SchemaCommonFlowHandler, user_input: dict[str, Any] +) -> dict[str, Any]: + """Validate config.""" + if all(x in user_input for x in (CONF_MIN_DUR, CONF_MAX_DUR)): + min_cycle = timedelta(**user_input[CONF_MIN_DUR]) + max_cycle = timedelta(**user_input[CONF_MAX_DUR]) + + if min_cycle >= max_cycle: + raise SchemaFlowError("min_max_runtime") + + return user_input + + CONFIG_FLOW = { "user": SchemaFlowFormStep(vol.Schema(CONFIG_SCHEMA), next_step="presets"), "presets": SchemaFlowFormStep(vol.Schema(PRESETS_SCHEMA)), } OPTIONS_FLOW = { - "init": SchemaFlowFormStep(vol.Schema(OPTIONS_SCHEMA), next_step="presets"), + "init": SchemaFlowFormStep( + vol.Schema(OPTIONS_SCHEMA), + validate_user_input=_validate_config, + next_step="presets", + ), "presets": SchemaFlowFormStep(vol.Schema(PRESETS_SCHEMA)), } @@ -104,7 +133,7 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow.""" - MINOR_VERSION = 2 + MINOR_VERSION = 3 config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW diff --git a/homeassistant/components/generic_thermostat/const.py b/homeassistant/components/generic_thermostat/const.py index d4c25f698d229b..902efc6c347c61 100644 --- a/homeassistant/components/generic_thermostat/const.py +++ b/homeassistant/components/generic_thermostat/const.py @@ -20,6 +20,8 @@ CONF_HOT_TOLERANCE = "hot_tolerance" CONF_MAX_TEMP = "max_temp" CONF_MIN_DUR = "min_cycle_duration" +CONF_MAX_DUR = "max_cycle_duration" +CONF_DUR_COOLDOWN = "cycle_cooldown" CONF_MIN_TEMP = "min_temp" CONF_PRESETS = { p: f"{p}_temp" diff --git a/homeassistant/components/generic_thermostat/strings.json b/homeassistant/components/generic_thermostat/strings.json index 5257be051a29b7..d81889c83cd4ce 100644 --- a/homeassistant/components/generic_thermostat/strings.json +++ b/homeassistant/components/generic_thermostat/strings.json @@ -16,11 +16,13 @@ "data": { "ac_mode": "Cooling mode", "cold_tolerance": "Cold tolerance", + "cycle_cooldown": "Cooldown period after running", "heater": "Actuator switch", "hot_tolerance": "Hot tolerance", "keep_alive": "Keep-alive interval", + "max_cycle_duration": "Maximum run time", "max_temp": "Maximum target temperature", - "min_cycle_duration": "Minimum cycle duration", + "min_cycle_duration": "Minimum run time", "min_temp": "Minimum target temperature", "name": "[%key:common::config_flow::data::name%]", "target_sensor": "Temperature sensor" @@ -28,10 +30,12 @@ "data_description": { "ac_mode": "Set the actuator specified to be treated as a cooling device instead of a heating device.", "cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor goes below 24.5.", + "cycle_cooldown": "After switching off, the minimum amount of time that must elapse before it can be switched back on.", "heater": "Switch entity used to cool or heat depending on A/C mode.", "hot_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched off. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will stop when the sensor equals or goes above 25.5.", - "keep_alive": "Trigger the heater periodically to keep devices from losing state. When set, min cycle duration is ignored.", - "min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on.", + "keep_alive": "Trigger the heater periodically to keep devices from losing state.", + "max_cycle_duration": "Once switched on, the maximum amount of time that can elapse before it will be switched off.", + "min_cycle_duration": "Once switched on, the minimum amount of time that must elapse before it may be switched off.", "target_sensor": "Temperature sensor that reflects the current temperature." }, "description": "Create a climate entity that controls the temperature via a switch and sensor.", @@ -40,14 +44,19 @@ } }, "options": { + "error": { + "min_max_runtime": "Minimum run time must be less than the maximum run time." + }, "step": { "init": { "data": { "ac_mode": "[%key:component::generic_thermostat::config::step::user::data::ac_mode%]", "cold_tolerance": "[%key:component::generic_thermostat::config::step::user::data::cold_tolerance%]", + "cycle_cooldown": "[%key:component::generic_thermostat::config::step::user::data::cycle_cooldown%]", "heater": "[%key:component::generic_thermostat::config::step::user::data::heater%]", "hot_tolerance": "[%key:component::generic_thermostat::config::step::user::data::hot_tolerance%]", "keep_alive": "[%key:component::generic_thermostat::config::step::user::data::keep_alive%]", + "max_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data::max_cycle_duration%]", "max_temp": "[%key:component::generic_thermostat::config::step::user::data::max_temp%]", "min_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data::min_cycle_duration%]", "min_temp": "[%key:component::generic_thermostat::config::step::user::data::min_temp%]", @@ -56,9 +65,11 @@ "data_description": { "ac_mode": "[%key:component::generic_thermostat::config::step::user::data_description::ac_mode%]", "cold_tolerance": "[%key:component::generic_thermostat::config::step::user::data_description::cold_tolerance%]", + "cycle_cooldown": "[%key:component::generic_thermostat::config::step::user::data_description::cycle_cooldown%]", "heater": "[%key:component::generic_thermostat::config::step::user::data_description::heater%]", "hot_tolerance": "[%key:component::generic_thermostat::config::step::user::data_description::hot_tolerance%]", "keep_alive": "[%key:component::generic_thermostat::config::step::user::data_description::keep_alive%]", + "max_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data_description::max_cycle_duration%]", "min_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data_description::min_cycle_duration%]", "target_sensor": "[%key:component::generic_thermostat::config::step::user::data_description::target_sensor%]" } diff --git a/homeassistant/components/geo_rss_events/sensor.py b/homeassistant/components/geo_rss_events/sensor.py index 079a47a6c27a18..34f5283b50c917 100644 --- a/homeassistant/components/geo_rss_events/sensor.py +++ b/homeassistant/components/geo_rss_events/sensor.py @@ -40,7 +40,6 @@ CONF_CATEGORIES = "categories" -DEFAULT_ICON = "mdi:alert" DEFAULT_NAME = "Event Service" DEFAULT_RADIUS_IN_KM = 20.0 DEFAULT_UNIT_OF_MEASUREMENT = "Events" @@ -111,15 +110,14 @@ def setup_platform( class GeoRssServiceSensor(SensorEntity): """Representation of a Sensor.""" + _attr_icon = "mdi:alert" + def __init__( self, coordinates, url, radius, category, service_name, unit_of_measurement ): """Initialize the sensor.""" - self._category = category - self._service_name = service_name - self._state = None - self._state_attributes = None - self._unit_of_measurement = unit_of_measurement + self._attr_name = f"{service_name} {'Any' if category is None else category}" + self._attr_native_unit_of_measurement = unit_of_measurement self._feed = GenericFeed( coordinates, @@ -128,31 +126,6 @@ def __init__( filter_categories=None if not category else [category], ) - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._service_name} {'Any' if self._category is None else self._category}" - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit_of_measurement - - @property - def icon(self): - """Return the default icon to use in the frontend.""" - return DEFAULT_ICON - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._state_attributes - def update(self) -> None: """Update this sensor from the GeoRSS service.""" @@ -161,14 +134,14 @@ def update(self) -> None: _LOGGER.debug( "Adding events to sensor %s: %s", self.entity_id, feed_entries ) - self._state = len(feed_entries) + self._attr_native_value = len(feed_entries) # And now compute the attributes from the filtered events. matrix = {} for entry in feed_entries: matrix[entry.title] = ( f"{entry.distance_to_home:.0f}{UnitOfLength.KILOMETERS}" ) - self._state_attributes = matrix + self._attr_extra_state_attributes = matrix elif status == UPDATE_OK_NO_DATA: _LOGGER.debug("Update successful, but no data received from %s", self._feed) # Don't change the state or state attributes. @@ -178,5 +151,5 @@ def update(self) -> None: ) # If no events were found due to an error then just set state to # zero. - self._state = 0 - self._state_attributes = {} + self._attr_native_value = 0 + self._attr_extra_state_attributes = {} diff --git a/homeassistant/components/geofency/strings.json b/homeassistant/components/geofency/strings.json index 82c6da6d5b2dd3..1df8b77c3d3cf1 100644 --- a/homeassistant/components/geofency/strings.json +++ b/homeassistant/components/geofency/strings.json @@ -2,6 +2,7 @@ "config": { "abort": { "cloud_not_connected": "[%key:common::config_flow::abort::cloud_not_connected%]", + "reconfigure_successful": "**Reconfiguration was successful**\n\nGo to the webhook feature in Geofency and update the webhook with the following settings:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details.", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]" }, @@ -9,6 +10,10 @@ "default": "To send events to Home Assistant, you will need to set up the webhook feature in Geofency.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details." }, "step": { + "reconfigure": { + "description": "Are you sure you want to reconfigure the Geofency webhook?", + "title": "Reconfigure Geofency webhook" + }, "user": { "description": "Are you sure you want to set up the Geofency webhook?", "title": "Set up the Geofency webhook" diff --git a/homeassistant/components/geonetnz_quakes/sensor.py b/homeassistant/components/geonetnz_quakes/sensor.py index cc4b4e16282834..d817a62dffb290 100644 --- a/homeassistant/components/geonetnz_quakes/sensor.py +++ b/homeassistant/components/geonetnz_quakes/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import Any from homeassistant.components.sensor import SensorEntity from homeassistant.core import HomeAssistant, callback @@ -22,8 +23,6 @@ ATTR_UPDATED = "updated" ATTR_REMOVED = "removed" -DEFAULT_ICON = "mdi:pulse" -DEFAULT_UNIT_OF_MEASUREMENT = "quakes" # An update of this entity is not making a web request, but uses internal data only. PARALLEL_UPDATES = 0 @@ -44,19 +43,20 @@ async def async_setup_entry( class GeonetnzQuakesSensor(SensorEntity): """Status sensor for the GeoNet NZ Quakes integration.""" + _attr_icon = "mdi:pulse" + _attr_native_unit_of_measurement = "quakes" _attr_should_poll = False def __init__(self, config_entry_id, config_unique_id, config_title, manager): """Initialize entity.""" self._config_entry_id = config_entry_id - self._config_unique_id = config_unique_id - self._config_title = config_title + self._attr_unique_id = config_unique_id + self._attr_name = f"GeoNet NZ Quakes ({config_title})" self._manager = manager self._status = None self._last_update = None self._last_update_successful = None self._last_timestamp = None - self._total = None self._created = None self._updated = None self._removed = None @@ -105,38 +105,13 @@ def _update_from_status_info(self, status_info): else: self._last_update_successful = None self._last_timestamp = status_info.last_timestamp - self._total = status_info.total + self._attr_native_value = status_info.total self._created = status_info.created self._updated = status_info.updated self._removed = status_info.removed @property - def native_value(self): - """Return the state of the sensor.""" - return self._total - - @property - def unique_id(self) -> str: - """Return a unique ID containing latitude/longitude.""" - return self._config_unique_id - - @property - def name(self) -> str | None: - """Return the name of the entity.""" - return f"GeoNet NZ Quakes ({self._config_title})" - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return DEFAULT_ICON - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return DEFAULT_UNIT_OF_MEASUREMENT - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the device state attributes.""" return { key: value diff --git a/homeassistant/components/geonetnz_volcano/sensor.py b/homeassistant/components/geonetnz_volcano/sensor.py index 159806778ce418..55fb7a477bf3a3 100644 --- a/homeassistant/components/geonetnz_volcano/sensor.py +++ b/homeassistant/components/geonetnz_volcano/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import Any from homeassistant.components.sensor import SensorEntity from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, UnitOfLength @@ -57,11 +58,12 @@ def async_add_sensor(feed_manager, external_id, unit_system): class GeonetnzVolcanoSensor(SensorEntity): """Represents an external event with GeoNet NZ Volcano feed data.""" + _attr_icon = DEFAULT_ICON + _attr_native_unit_of_measurement = "alert level" _attr_should_poll = False def __init__(self, config_entry_id, feed_manager, external_id, unit_system): """Initialize entity with data from feed entry.""" - self._config_entry_id = config_entry_id self._feed_manager = feed_manager self._external_id = external_id self._attr_unique_id = f"{config_entry_id}_{external_id}" @@ -70,8 +72,6 @@ def __init__(self, config_entry_id, feed_manager, external_id, unit_system): self._distance = None self._latitude = None self._longitude = None - self._attribution = None - self._alert_level = None self._activity = None self._hazards = None self._feed_last_update = None @@ -123,7 +123,7 @@ def _update_from_feed(self, feed_entry, last_update, last_update_successful): self._latitude = round(feed_entry.coordinates[0], 5) self._longitude = round(feed_entry.coordinates[1], 5) self._attr_attribution = feed_entry.attribution - self._alert_level = feed_entry.alert_level + self._attr_native_value = feed_entry.alert_level self._activity = feed_entry.activity self._hazards = feed_entry.hazards self._feed_last_update = dt_util.as_utc(last_update) if last_update else None @@ -132,27 +132,12 @@ def _update_from_feed(self, feed_entry, last_update, last_update_successful): ) @property - def native_value(self): - """Return the state of the sensor.""" - return self._alert_level - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return DEFAULT_ICON - - @property - def name(self) -> str | None: + def name(self) -> str: """Return the name of the entity.""" return f"Volcano {self._title}" @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return "alert level" - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the device state attributes.""" return { key: value diff --git a/homeassistant/components/ghost/config_flow.py b/homeassistant/components/ghost/config_flow.py index 59b2e65090e0f2..44d6600e55d21b 100644 --- a/homeassistant/components/ghost/config_flow.py +++ b/homeassistant/components/ghost/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -16,6 +17,8 @@ _LOGGER = logging.getLogger(__name__) +GHOST_INTEGRATION_SETUP_URL = "https://account.ghost.org/?r=settings/integrations/new" + STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_API_URL): str, @@ -23,12 +26,64 @@ } ) +STEP_REAUTH_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_ADMIN_API_KEY): str, + } +) + class GhostConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Ghost.""" VERSION = 1 + async def async_step_reauth( + self, entry_data: 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 reauth confirmation.""" + reauth_entry = self._get_reauth_entry() + errors: dict[str, str] = {} + + if user_input is not None: + admin_api_key = user_input[CONF_ADMIN_API_KEY] + + if ":" not in admin_api_key: + errors["base"] = "invalid_api_key" + else: + try: + await self._validate_credentials( + reauth_entry.data[CONF_API_URL], admin_api_key + ) + except GhostAuthError: + errors["base"] = "invalid_auth" + except GhostError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error during Ghost reauth") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates=user_input, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_REAUTH_DATA_SCHEMA, + errors=errors, + description_placeholders={ + "title": reauth_entry.title, + "setup_url": GHOST_INTEGRATION_SETUP_URL, + }, + ) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -50,9 +105,51 @@ async def async_step_user( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors, - description_placeholders={ - "docs_url": "https://account.ghost.org/?r=settings/integrations/new" - }, + description_placeholders={"setup_url": GHOST_INTEGRATION_SETUP_URL}, + ) + + 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: + api_url = user_input[CONF_API_URL].rstrip("/") + admin_api_key = user_input[CONF_ADMIN_API_KEY] + + if ":" not in admin_api_key: + errors["base"] = "invalid_api_key" + else: + try: + site = await self._validate_credentials(api_url, admin_api_key) + except GhostAuthError: + errors["base"] = "invalid_auth" + except GhostError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error during Ghost reconfigure") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(site["site_uuid"]) + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={ + CONF_API_URL: api_url, + CONF_ADMIN_API_KEY: admin_api_key, + }, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, + suggested_values=user_input or reconfigure_entry.data, + ), + errors=errors, + description_placeholders={"setup_url": GHOST_INTEGRATION_SETUP_URL}, ) async def _validate_credentials( @@ -89,7 +186,7 @@ async def _validate_and_create( site_title = site["title"] - await self.async_set_unique_id(site["uuid"]) + await self.async_set_unique_id(site["site_uuid"]) self._abort_if_unique_id_configured() return self.async_create_entry( diff --git a/homeassistant/components/ghost/diagnostics.py b/homeassistant/components/ghost/diagnostics.py new file mode 100644 index 00000000000000..db24c9de6a45f5 --- /dev/null +++ b/homeassistant/components/ghost/diagnostics.py @@ -0,0 +1,27 @@ +"""Diagnostics support for Ghost.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from . import GhostConfigEntry +from .const import CONF_ADMIN_API_KEY + +TO_REDACT = {CONF_ADMIN_API_KEY} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: GhostConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return async_redact_data( + { + "entry_data": dict(config_entry.data), + "coordinator_data": asdict(config_entry.runtime_data.coordinator.data), + }, + TO_REDACT, + ) diff --git a/homeassistant/components/ghost/manifest.json b/homeassistant/components/ghost/manifest.json index 6b263540c6a5a7..41546c0ee6b658 100644 --- a/homeassistant/components/ghost/manifest.json +++ b/homeassistant/components/ghost/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aioghost"], - "quality_scale": "bronze", + "quality_scale": "gold", "requirements": ["aioghost==0.4.0"] } diff --git a/homeassistant/components/ghost/quality_scale.yaml b/homeassistant/components/ghost/quality_scale.yaml index 506d69d83fcfe6..55bc8670dc9489 100644 --- a/homeassistant/components/ghost/quality_scale.yaml +++ b/homeassistant/components/ghost/quality_scale.yaml @@ -38,12 +38,12 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: Cloud service integration, not discoverable. @@ -68,13 +68,11 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: No repair scenarios identified for this integration. - stale-devices: - status: todo - comment: Remove newsletter entities when newsletter is removed + stale-devices: done # Platinum async-dependency: done diff --git a/homeassistant/components/ghost/sensor.py b/homeassistant/components/ghost/sensor.py index 9986edc9dee5ac..9fd3ea977c6576 100644 --- a/homeassistant/components/ghost/sensor.py +++ b/homeassistant/components/ghost/sensor.py @@ -12,7 +12,9 @@ SensorEntityDescription, SensorStateClass, ) +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -210,36 +212,67 @@ async def async_setup_entry( async_add_entities(entities) + # Remove stale newsletter entities left over from previous runs. + entity_registry = er.async_get(hass) + prefix = f"{entry.unique_id}_newsletter_" + active_newsletters = { + newsletter_id + for newsletter_id, newsletter in coordinator.data.newsletters.items() + if newsletter.get("status") == "active" + } + for entity_entry in er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ): + if ( + entity_entry.unique_id.startswith(prefix) + and entity_entry.unique_id[len(prefix) :] not in active_newsletters + ): + entity_registry.async_remove(entity_entry.entity_id) + newsletter_added: set[str] = set() @callback - def _async_add_newsletter_entities() -> None: - """Add newsletter entities when new newsletters appear.""" + def _async_update_newsletter_entities() -> None: + """Add new and remove stale newsletter entities.""" nonlocal newsletter_added - new_newsletters = { + active_newsletters = { newsletter_id for newsletter_id, newsletter in coordinator.data.newsletters.items() if newsletter.get("status") == "active" - } - newsletter_added - - if not new_newsletters: - return - - async_add_entities( - GhostNewsletterSensorEntity( - coordinator, - entry, - newsletter_id, - coordinator.data.newsletters[newsletter_id].get("name", "Newsletter"), + } + + new_newsletters = active_newsletters - newsletter_added + + if new_newsletters: + async_add_entities( + GhostNewsletterSensorEntity( + coordinator, + entry, + newsletter_id, + coordinator.data.newsletters[newsletter_id].get( + "name", "Newsletter" + ), + ) + for newsletter_id in new_newsletters ) - for newsletter_id in new_newsletters - ) - newsletter_added |= new_newsletters - - _async_add_newsletter_entities() + newsletter_added.update(new_newsletters) + + removed_newsletters = newsletter_added - active_newsletters + if removed_newsletters: + entity_registry = er.async_get(hass) + for newsletter_id in removed_newsletters: + unique_id = f"{entry.unique_id}_newsletter_{newsletter_id}" + entity_id = entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, unique_id + ) + if entity_id: + entity_registry.async_remove(entity_id) + newsletter_added -= removed_newsletters + + _async_update_newsletter_entities() entry.async_on_unload( - coordinator.async_add_listener(_async_add_newsletter_entities) + coordinator.async_add_listener(_async_update_newsletter_entities) ) @@ -310,9 +343,10 @@ def _get_newsletter_by_id(self) -> dict[str, Any] | None: @property def available(self) -> bool: """Return True if the entity is available.""" - if not super().available or self.coordinator.data is None: - return False - return self._newsletter_id in self.coordinator.data.newsletters + return ( + super().available + and self._newsletter_id in self.coordinator.data.newsletters + ) @property def native_value(self) -> int | None: diff --git a/homeassistant/components/ghost/strings.json b/homeassistant/components/ghost/strings.json index a9ae0090d3cf04..7713705e4e1f9a 100644 --- a/homeassistant/components/ghost/strings.json +++ b/homeassistant/components/ghost/strings.json @@ -1,7 +1,10 @@ { "config": { "abort": { - "already_configured": "This Ghost site is already configured." + "already_configured": "This Ghost site is already configured.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "The provided credentials belong to a different Ghost site." }, "error": { "cannot_connect": "Failed to connect to Ghost. Please check your URL.", @@ -10,6 +13,27 @@ "unknown": "An unexpected error occurred." }, "step": { + "reauth_confirm": { + "data": { + "admin_api_key": "[%key:component::ghost::config::step::user::data::admin_api_key%]" + }, + "data_description": { + "admin_api_key": "[%key:component::ghost::config::step::user::data_description::admin_api_key%]" + }, + "description": "Your API key for {title} is invalid. [Create a new integration key]({setup_url}) to reauthenticate.", + "title": "[%key:common::config_flow::title::reauth%]" + }, + "reconfigure": { + "data": { + "admin_api_key": "[%key:component::ghost::config::step::user::data::admin_api_key%]", + "api_url": "[%key:component::ghost::config::step::user::data::api_url%]" + }, + "data_description": { + "admin_api_key": "[%key:component::ghost::config::step::user::data_description::admin_api_key%]", + "api_url": "[%key:component::ghost::config::step::user::data_description::api_url%]" + }, + "description": "Update the configuration for your Ghost integration. [Create a custom integration]({setup_url}) to get your API URL and Admin API key." + }, "user": { "data": { "admin_api_key": "Admin API key", @@ -19,7 +43,7 @@ "admin_api_key": "The Admin API key for your Ghost integration", "api_url": "The API URL for your Ghost integration" }, - "description": "[Create a custom integration]({docs_url}) to get your API URL and Admin API key.", + "description": "[Create a custom integration]({setup_url}) to get your API URL and Admin API key.", "title": "Connect to Ghost" } } diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py index 31f704fcaccadf..e19b1d280d2b97 100644 --- a/homeassistant/components/gios/__init__.py +++ b/homeassistant/components/gios/__init__.py @@ -8,15 +8,14 @@ from gios import Gios from gios.exceptions import GiosError -from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_STATION_ID, DOMAIN -from .coordinator import GiosConfigEntry, GiosData, GiosDataUpdateCoordinator +from .coordinator import GiosConfigEntry, GiosDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -56,19 +55,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: GiosConfigEntry) -> bool coordinator = GiosDataUpdateCoordinator(hass, entry, gios) await coordinator.async_config_entry_first_refresh() - entry.runtime_data = GiosData(coordinator) + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # Remove air_quality entities from registry if they exist - ent_reg = er.async_get(hass) - unique_id = str(coordinator.gios.station_id) - if entity_id := ent_reg.async_get_entity_id( - AIR_QUALITY_PLATFORM, DOMAIN, unique_id - ): - _LOGGER.debug("Removing deprecated air_quality entity %s", entity_id) - ent_reg.async_remove(entity_id) - return True diff --git a/homeassistant/components/gios/config_flow.py b/homeassistant/components/gios/config_flow.py index 5745d15e72e811..eb83e92bc03356 100644 --- a/homeassistant/components/gios/config_flow.py +++ b/homeassistant/components/gios/config_flow.py @@ -38,14 +38,18 @@ async def async_step_user( if user_input is not None: station_id = user_input[CONF_STATION_ID] - try: - await self.async_set_unique_id(station_id, raise_on_progress=False) - self._abort_if_unique_id_configured() + await self.async_set_unique_id(station_id, raise_on_progress=False) + self._abort_if_unique_id_configured() + try: async with asyncio.timeout(API_TIMEOUT): gios = await Gios.create(websession, int(station_id)) await gios.async_update() - + except ApiError, ClientConnectorError, TimeoutError: + errors["base"] = "cannot_connect" + except InvalidSensorsDataError: + errors[CONF_STATION_ID] = "invalid_sensors_data" + else: # GIOS treats station ID as int user_input[CONF_STATION_ID] = int(station_id) @@ -60,10 +64,6 @@ async def async_step_user( # raising errors. data={**user_input, CONF_NAME: gios.station_name}, ) - except ApiError, ClientConnectorError, TimeoutError: - errors["base"] = "cannot_connect" - except InvalidSensorsDataError: - errors[CONF_STATION_ID] = "invalid_sensors_data" try: gios = await Gios.create(websession) diff --git a/homeassistant/components/gios/coordinator.py b/homeassistant/components/gios/coordinator.py index c80557da55f24e..60525b33edf297 100644 --- a/homeassistant/components/gios/coordinator.py +++ b/homeassistant/components/gios/coordinator.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass import logging from typing import TYPE_CHECKING @@ -22,14 +21,7 @@ _LOGGER = logging.getLogger(__name__) -type GiosConfigEntry = ConfigEntry[GiosData] - - -@dataclass -class GiosData: - """Data for GIOS integration.""" - - coordinator: GiosDataUpdateCoordinator +type GiosConfigEntry = ConfigEntry[GiosDataUpdateCoordinator] class GiosDataUpdateCoordinator(DataUpdateCoordinator[GiosSensors]): diff --git a/homeassistant/components/gios/diagnostics.py b/homeassistant/components/gios/diagnostics.py index 7e938d5ac6b58a..e25f56dcbc70ab 100644 --- a/homeassistant/components/gios/diagnostics.py +++ b/homeassistant/components/gios/diagnostics.py @@ -14,7 +14,7 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: GiosConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator = config_entry.runtime_data.coordinator + coordinator = config_entry.runtime_data return { "config_entry": config_entry.as_dict(), diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index 5cdd0d513a3362..e92e14ae555397 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -7,5 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["dacite", "gios"], + "quality_scale": "platinum", "requirements": ["gios==7.0.0"] } diff --git a/homeassistant/components/gios/quality_scale.yaml b/homeassistant/components/gios/quality_scale.yaml index cab565d35cf838..f1b25b15b55cb5 100644 --- a/homeassistant/components/gios/quality_scale.yaml +++ b/homeassistant/components/gios/quality_scale.yaml @@ -1,7 +1,4 @@ rules: - # Other comments: - # - we could consider removing the air quality entity removal - # Bronze action-setup: status: exempt @@ -9,14 +6,8 @@ rules: appropriate-polling: done brands: done common-modules: done - config-flow-test-coverage: - status: todo - comment: - We should have the happy flow as the first test, which can be merged with test_show_form. - The config flow tests are missing adding a duplicate entry test. - config-flow: - status: todo - comment: Limit the scope of the try block in the user step + config-flow-test-coverage: done + config-flow: done dependency-transparency: done docs-actions: status: exempt @@ -27,9 +18,7 @@ rules: entity-event-setup: done entity-unique-id: done has-entity-name: done - runtime-data: - status: todo - comment: No direct need to wrap the coordinator in a dataclass to store in the config entry + runtime-data: done test-before-configure: done test-before-setup: done unique-config-entry: done @@ -50,11 +39,7 @@ rules: reauthentication-flow: status: exempt comment: This integration does not require authentication. - test-coverage: - status: todo - comment: - The `test_async_setup_entry` should test the state of the mock config entry, instead of an entity state - The `test_availability` doesn't really do what it says it does, and this is now already tested via the snapshot tests. + test-coverage: done # Gold devices: done @@ -78,13 +63,9 @@ rules: status: exempt comment: This integration does not have devices. entity-category: done - entity-device-class: - status: todo - comment: We can use the CO device class for the carbon monoxide sensor + entity-device-class: done entity-disabled-by-default: done - entity-translations: - status: todo - comment: We can remove the options state_attributes. + entity-translations: done exception-translations: done icon-translations: done reconfiguration-flow: diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index 7fb6fcf431ce42..5304fb98cf246d 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -9,7 +9,7 @@ from gios.model import GiosSensors from homeassistant.components.sensor import ( - DOMAIN as PLATFORM, + DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -72,9 +72,9 @@ class GiosSensorEntityDescription(SensorEntityDescription): key=ATTR_CO, value=lambda sensors: sensors.co.value if sensors.co else None, suggested_display_precision=0, + device_class=SensorDeviceClass.CO, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, - translation_key="co", ), GiosSensorEntityDescription( key=ATTR_NO, @@ -181,13 +181,13 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a GIOS entities from a config_entry.""" - coordinator = entry.runtime_data.coordinator + coordinator = entry.runtime_data # Due to the change of the attribute name of one sensor, it is necessary to migrate # the unique_id to the new name. entity_registry = er.async_get(hass) old_unique_id = f"{coordinator.gios.station_id}-pm2.5" if entity_id := entity_registry.async_get_entity_id( - PLATFORM, DOMAIN, old_unique_id + SENSOR_DOMAIN, DOMAIN, old_unique_id ): new_unique_id = f"{coordinator.gios.station_id}-{ATTR_PM25}" _LOGGER.debug( diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json index da9c246600a99f..09d9a1dfc7b7dd 100644 --- a/homeassistant/components/gios/strings.json +++ b/homeassistant/components/gios/strings.json @@ -31,26 +31,11 @@ "sufficient": "Sufficient", "very_bad": "Very bad", "very_good": "Very good" - }, - "state_attributes": { - "options": { - "state": { - "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", - "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", - "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", - "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", - "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", - "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - } - } } }, "c6h6": { "name": "Benzene" }, - "co": { - "name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]" - }, "no2_index": { "name": "Nitrogen dioxide index", "state": { @@ -60,18 +45,6 @@ "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - }, - "state_attributes": { - "options": { - "state": { - "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", - "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", - "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", - "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", - "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", - "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - } - } } }, "nox": { @@ -86,18 +59,6 @@ "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - }, - "state_attributes": { - "options": { - "state": { - "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", - "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", - "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", - "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", - "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", - "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - } - } } }, "pm10_index": { @@ -109,18 +70,6 @@ "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - }, - "state_attributes": { - "options": { - "state": { - "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", - "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", - "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", - "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", - "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", - "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - } - } } }, "pm25_index": { @@ -132,18 +81,6 @@ "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - }, - "state_attributes": { - "options": { - "state": { - "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", - "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", - "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", - "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", - "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", - "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - } - } } }, "so2_index": { @@ -155,18 +92,6 @@ "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - }, - "state_attributes": { - "options": { - "state": { - "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", - "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", - "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", - "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", - "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", - "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - } - } } } } diff --git a/homeassistant/components/github/coordinator.py b/homeassistant/components/github/coordinator.py index 8b531907996312..d50728d47c3eff 100644 --- a/homeassistant/components/github/coordinator.py +++ b/homeassistant/components/github/coordinator.py @@ -78,6 +78,12 @@ number } } + merged_pull_request: pullRequests( + first:1 + states: MERGED + ) { + total: totalCount + } release: latestRelease { name url diff --git a/homeassistant/components/github/icons.json b/homeassistant/components/github/icons.json index 2f6696980b7585..90f15bb550acaa 100644 --- a/homeassistant/components/github/icons.json +++ b/homeassistant/components/github/icons.json @@ -28,6 +28,9 @@ "latest_tag": { "default": "mdi:tag" }, + "merged_pulls_count": { + "default": "mdi:source-merge" + }, "pulls_count": { "default": "mdi:source-pull" }, diff --git a/homeassistant/components/github/manifest.json b/homeassistant/components/github/manifest.json index 7486c802fc6a66..8dd5a06eaf861a 100644 --- a/homeassistant/components/github/manifest.json +++ b/homeassistant/components/github/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aiogithubapi"], - "requirements": ["aiogithubapi==24.6.0"] + "requirements": ["aiogithubapi==26.0.0"] } diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py index 35985ed50d5276..744fb23001e4e3 100644 --- a/homeassistant/components/github/sensor.py +++ b/homeassistant/components/github/sensor.py @@ -75,6 +75,13 @@ class GitHubSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data["pull_request"]["total"], ), + GitHubSensorEntityDescription( + key="merged_pulls_count", + translation_key="merged_pulls_count", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL, + value_fn=lambda data: data["merged_pull_request"]["total"], + ), GitHubSensorEntityDescription( key="latest_commit", translation_key="latest_commit", diff --git a/homeassistant/components/github/strings.json b/homeassistant/components/github/strings.json index 205b641ed9af46..808e87bfe3fd73 100644 --- a/homeassistant/components/github/strings.json +++ b/homeassistant/components/github/strings.json @@ -48,6 +48,10 @@ "latest_tag": { "name": "Latest tag" }, + "merged_pulls_count": { + "name": "Merged pull requests", + "unit_of_measurement": "pull requests" + }, "pulls_count": { "name": "Pull requests", "unit_of_measurement": "pull requests" diff --git a/homeassistant/components/gitter/sensor.py b/homeassistant/components/gitter/sensor.py index 957ac4e9d8c648..950dc319da4608 100644 --- a/homeassistant/components/gitter/sensor.py +++ b/homeassistant/components/gitter/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import Any from gitterpy.client import GitterClient from gitterpy.errors import GitterRoomError, GitterTokenError @@ -90,7 +91,7 @@ def native_unit_of_measurement(self): return self._unit_of_measurement @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return { ATTR_USERNAME: self._username, diff --git a/homeassistant/components/gogogate2/entity.py b/homeassistant/components/gogogate2/entity.py index a6879f038bc01d..f82e4d1f150028 100644 --- a/homeassistant/components/gogogate2/entity.py +++ b/homeassistant/components/gogogate2/entity.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + from ismartgate.common import AbstractDoor, get_door_by_id from homeassistant.const import CONF_IP_ADDRESS @@ -62,6 +64,6 @@ def device_info(self) -> DeviceInfo: ) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return {"door_id": self._door_id} diff --git a/homeassistant/components/gogogate2/sensor.py b/homeassistant/components/gogogate2/sensor.py index c594671b34f8ad..4e4fa908b8f1a9 100644 --- a/homeassistant/components/gogogate2/sensor.py +++ b/homeassistant/components/gogogate2/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from itertools import chain +from typing import Any from ismartgate.common import AbstractDoor, get_configured_doors @@ -49,7 +50,7 @@ class DoorSensorEntity(GoGoGate2Entity, SensorEntity): """Base class for door sensor entities.""" @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" attrs = super().extra_state_attributes door = self.door diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index f6d6df98054e19..72bfd94ce7391f 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -8,5 +8,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==12.1.3"] + "requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==13.2.2"] } diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 6d4c9e1d21927a..929944cb4893e1 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -29,6 +29,7 @@ area_registry as ar, device_registry as dr, entity_registry as er, + intent, start, ) from homeassistant.helpers.event import async_call_later @@ -597,7 +598,6 @@ def sync_serialize(self, agent_user_id, instance_uuid): state = self.state traits = self.traits() entity_config = self.config.entity_config.get(state.entity_id, {}) - name = (entity_config.get(CONF_NAME) or state.name).strip() # Find entity/device/area registry entries entity_entry, device_entry, area_entry = _get_registry_entries( @@ -607,7 +607,6 @@ def sync_serialize(self, agent_user_id, instance_uuid): # Build the device info device = { "id": state.entity_id, - "name": {"name": name}, "attributes": {}, "traits": [trait.name for trait in traits], "willReportState": self.config.should_report_state, @@ -615,13 +614,18 @@ def sync_serialize(self, agent_user_id, instance_uuid): state.domain, state.attributes.get(ATTR_DEVICE_CLASS) ), } - # Add aliases - if (config_aliases := entity_config.get(CONF_ALIASES, [])) or ( - entity_entry and entity_entry.aliases - ): - device["name"]["nicknames"] = [name, *config_aliases] - if entity_entry: - device["name"]["nicknames"].extend(entity_entry.aliases) + # Add name and aliases. + # The entity's alias list is ordered: the first slot naturally serves + # as the primary name (set to the auto-generated full entity name by + # default), while the rest serve as alternative names (nicknames). + aliases = intent.async_get_entity_aliases( + self.hass, entity_entry, state=state, allow_empty=False + ) + name, *aliases = aliases + name = entity_config.get(CONF_NAME) or name + device["name"] = {"name": name} + if (config_aliases := entity_config.get(CONF_ALIASES, [])) or aliases: + device["name"]["nicknames"] = [name, *config_aliases, *aliases] # Add local SDK info if enabled if self.config.is_local_sdk_active and self.should_expose_local(): diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 593d827864df12..5ae72b7a41ae7a 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -1752,15 +1752,15 @@ def __init__(self, hass, state, config): """Initialize a trait for a state.""" super().__init__(hass, state, config) if state.domain == fan.DOMAIN: - speed_count = min( - FAN_SPEED_MAX_SPEED_COUNT, - round( - 100 / (self.state.attributes.get(fan.ATTR_PERCENTAGE_STEP) or 1.0) - ), + speed_count = round( + 100 / (self.state.attributes.get(fan.ATTR_PERCENTAGE_STEP) or 1.0) ) - self._ordered_speed = [ - f"{speed}/{speed_count}" for speed in range(1, speed_count + 1) - ] + if speed_count <= FAN_SPEED_MAX_SPEED_COUNT: + self._ordered_speed = [ + f"{speed}/{speed_count}" for speed in range(1, speed_count + 1) + ] + else: + self._ordered_speed = [] @staticmethod def supported(domain, features, device_class, _): @@ -1786,7 +1786,11 @@ def sync_attributes(self) -> dict[str, Any]: result.update( { "reversible": reversible, - "supportsFanSpeedPercent": True, + # supportsFanSpeedPercent is mutually exclusive with + # availableFanSpeeds, where supportsFanSpeedPercent takes + # precedence. Report it only when step speeds are not + # supported so Google renders a percent slider (1-100%). + "supportsFanSpeedPercent": not self._ordered_speed, } ) @@ -1832,10 +1836,12 @@ def query_attributes(self) -> dict[str, Any]: if domain == fan.DOMAIN: percent = attrs.get(fan.ATTR_PERCENTAGE) or 0 - response["currentFanSpeedPercent"] = percent - response["currentFanSpeedSetting"] = percentage_to_ordered_list_item( - self._ordered_speed, percent - ) + if self._ordered_speed: + response["currentFanSpeedSetting"] = percentage_to_ordered_list_item( + self._ordered_speed, percent + ) + else: + response["currentFanSpeedPercent"] = percent return response @@ -1855,7 +1861,7 @@ async def execute_fanspeed(self, data, params): ) if domain == fan.DOMAIN: - if fan_speed := params.get("fanSpeed"): + if self._ordered_speed and (fan_speed := params.get("fanSpeed")): fan_speed_percent = ordered_list_item_to_percentage( self._ordered_speed, fan_speed ) diff --git a/homeassistant/components/google_assistant_sdk/helpers.py b/homeassistant/components/google_assistant_sdk/helpers.py index a3ced9fd68bccd..b8318436a3a50b 100644 --- a/homeassistant/components/google_assistant_sdk/helpers.py +++ b/homeassistant/components/google_assistant_sdk/helpers.py @@ -19,7 +19,7 @@ ATTR_MEDIA_ANNOUNCE, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, - DOMAIN as DOMAIN_MP, + DOMAIN as MP_DOMAIN, SERVICE_PLAY_MEDIA, MediaType, ) @@ -112,7 +112,7 @@ async def async_send_text_commands( ) ) await hass.services.async_call( - DOMAIN_MP, + MP_DOMAIN, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: media_players, diff --git a/homeassistant/components/google_drive/__init__.py b/homeassistant/components/google_drive/__init__.py index d3fe021a5a296c..a566f57f7e0415 100644 --- a/homeassistant/components/google_drive/__init__.py +++ b/homeassistant/components/google_drive/__init__.py @@ -12,6 +12,7 @@ from homeassistant.helpers import instance_id from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, OAuth2Session, async_get_config_entry_implementation, ) @@ -30,11 +31,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry) -> bool: """Set up Google Drive from a config entry.""" + try: + implementation = await async_get_config_entry_implementation(hass, entry) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err + auth = AsyncConfigEntryAuth( async_get_clientsession(hass), - OAuth2Session( - hass, entry, await async_get_config_entry_implementation(hass, entry) - ), + OAuth2Session(hass, entry, implementation), ) # Test we can refresh the token and raise ConfigEntryAuthFailed or ConfigEntryNotReady if not @@ -46,7 +53,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry) try: folder_id, _ = await client.async_create_ha_root_folder_if_not_exists() except GoogleDriveApiError as err: - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_get_folder", + translation_placeholders={"folder": "Home Assistant"}, + ) from err def async_notify_backup_listeners() -> None: for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): diff --git a/homeassistant/components/google_drive/api.py b/homeassistant/components/google_drive/api.py index 035c19717b82fb..909b85bb713e3e 100644 --- a/homeassistant/components/google_drive/api.py +++ b/homeassistant/components/google_drive/api.py @@ -22,6 +22,8 @@ ) from homeassistant.helpers import config_entry_oauth2_flow +from .const import DOMAIN + _UPLOAD_AND_DOWNLOAD_TIMEOUT = 12 * 3600 _UPLOAD_MAX_RETRIES = 20 @@ -61,14 +63,21 @@ async def async_get_access_token(self) -> str: ): if isinstance(ex, ClientResponseError) and 400 <= ex.status < 500: raise ConfigEntryAuthFailed( - "OAuth session is not valid, reauth required" + translation_domain=DOMAIN, + translation_key="authentication_not_valid", ) from ex - raise ConfigEntryNotReady from ex + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="authentication_failed", + ) from ex if hasattr(ex, "status") and ex.status == 400: self._oauth_session.config_entry.async_start_reauth( self._oauth_session.hass ) - raise HomeAssistantError(ex) from ex + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="authentication_failed", + ) from ex return str(self._oauth_session.token[CONF_ACCESS_TOKEN]) diff --git a/homeassistant/components/google_drive/backup.py b/homeassistant/components/google_drive/backup.py index bc306fe61d71b7..e6967d95eaf7bd 100644 --- a/homeassistant/components/google_drive/backup.py +++ b/homeassistant/components/google_drive/backup.py @@ -13,6 +13,7 @@ BackupAgent, BackupAgentError, BackupNotFound, + OnProgressCallback, ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -75,6 +76,7 @@ async def async_upload_backup( *, open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], backup: AgentBackup, + on_progress: OnProgressCallback, **kwargs: Any, ) -> None: """Upload a backup. diff --git a/homeassistant/components/google_drive/config_flow.py b/homeassistant/components/google_drive/config_flow.py index cfcff47f658a2f..ca117be7513868 100644 --- a/homeassistant/components/google_drive/config_flow.py +++ b/homeassistant/components/google_drive/config_flow.py @@ -8,7 +8,11 @@ from google_drive_api.exceptions import GoogleDriveApiError -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + ConfigFlowResult, +) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.helpers import config_entry_oauth2_flow, instance_id from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -44,6 +48,12 @@ def extra_authorize_data(self) -> dict[str, Any]: "prompt": "consent", } + 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) + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: @@ -81,13 +91,16 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResu await self.async_set_unique_id(email_address) - if self.source == SOURCE_REAUTH: - reauth_entry = self._get_reauth_entry() + if self.source in (SOURCE_REAUTH, SOURCE_RECONFIGURE): + if self.source == SOURCE_REAUTH: + entry = self._get_reauth_entry() + else: + entry = self._get_reconfigure_entry() self._abort_if_unique_id_mismatch( reason="wrong_account", - description_placeholders={"email": cast(str, reauth_entry.unique_id)}, + description_placeholders={"email": cast(str, entry.unique_id)}, ) - return self.async_update_reload_and_abort(reauth_entry, data=data) + return self.async_update_reload_and_abort(entry, data=data) self._abort_if_unique_id_configured() diff --git a/homeassistant/components/google_drive/quality_scale.yaml b/homeassistant/components/google_drive/quality_scale.yaml index b4fb1bcf42fce2..783e735c9edd52 100644 --- a/homeassistant/components/google_drive/quality_scale.yaml +++ b/homeassistant/components/google_drive/quality_scale.yaml @@ -17,9 +17,7 @@ rules: docs-removal-instructions: done entity-event-setup: done entity-unique-id: done - has-entity-name: - status: exempt - comment: No entities. + has-entity-name: done runtime-data: done test-before-configure: done test-before-setup: done @@ -66,12 +64,8 @@ rules: entity-disabled-by-default: done entity-translations: done exception-translations: done - icon-translations: - status: exempt - comment: No entities. - reconfiguration-flow: - status: exempt - comment: No configuration options. + icon-translations: done + reconfiguration-flow: done repair-issues: status: exempt comment: No repairs. diff --git a/homeassistant/components/google_drive/strings.json b/homeassistant/components/google_drive/strings.json index 8a64cddd68a584..ff0068d65e296c 100644 --- a/homeassistant/components/google_drive/strings.json +++ b/homeassistant/components/google_drive/strings.json @@ -18,6 +18,7 @@ "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%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]", "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", "wrong_account": "Wrong account: Please authenticate with {email}." @@ -62,5 +63,22 @@ "name": "Used storage in Drive Trash" } } + }, + "exceptions": { + "authentication_failed": { + "message": "Authentication failed" + }, + "authentication_not_valid": { + "message": "OAuth session is not valid, reauthentication required" + }, + "failed_to_get_folder": { + "message": "Failed to get {folder} folder" + }, + "invalid_response_google_drive_error": { + "message": "Invalid response from Google Drive: {error}" + }, + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + } } } diff --git a/homeassistant/components/google_sheets/__init__.py b/homeassistant/components/google_sheets/__init__.py index ff0ce62ec2416f..9998134815177c 100644 --- a/homeassistant/components/google_sheets/__init__.py +++ b/homeassistant/components/google_sheets/__init__.py @@ -7,7 +7,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN 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 from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, @@ -39,11 +44,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( - "OAuth session is not valid, reauth required" - ) from err + except OAuth2TokenRequestReauthError as err: + raise ConfigEntryAuthFailed( + "OAuth session is not valid, reauth required" + ) from err + except OAuth2TokenRequestError as err: raise ConfigEntryNotReady from err except aiohttp.ClientError as err: raise ConfigEntryNotReady from err diff --git a/homeassistant/components/google_translate/strings.json b/homeassistant/components/google_translate/strings.json index 6d35f3dbe8bd47..931036c78d900b 100644 --- a/homeassistant/components/google_translate/strings.json +++ b/homeassistant/components/google_translate/strings.json @@ -11,5 +11,10 @@ } } } + }, + "device": { + "google_translate": { + "name": "Google Translate {lang} {tld}" + } } } diff --git a/homeassistant/components/google_translate/tts.py b/homeassistant/components/google_translate/tts.py index 201300d95b4a94..ef293a71093faf 100644 --- a/homeassistant/components/google_translate/tts.py +++ b/homeassistant/components/google_translate/tts.py @@ -19,6 +19,7 @@ from homeassistant.config_entries 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 @@ -26,6 +27,7 @@ CONF_TLD, DEFAULT_LANG, DEFAULT_TLD, + DOMAIN, MAP_LANG_TLD, SUPPORT_LANGUAGES, SUPPORT_TLD, @@ -66,6 +68,9 @@ async def async_setup_entry( class GoogleTTSEntity(TextToSpeechEntity): """The Google speech API entity.""" + _attr_supported_languages = SUPPORT_LANGUAGES + _attr_supported_options = SUPPORT_OPTIONS + def __init__(self, config_entry: ConfigEntry, lang: str, tld: str) -> None: """Init Google TTS service.""" if lang in MAP_LANG_TLD: @@ -77,20 +82,15 @@ def __init__(self, config_entry: ConfigEntry, lang: str, tld: str) -> None: self._attr_name = f"Google Translate {self._lang} {self._tld}" self._attr_unique_id = config_entry.entry_id - @property - def default_language(self) -> str: - """Return the default language.""" - return self._lang - - @property - def supported_languages(self) -> list[str]: - """Return list of supported languages.""" - return SUPPORT_LANGUAGES - - @property - def supported_options(self) -> list[str]: - """Return a list of supported options.""" - return SUPPORT_OPTIONS + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, config_entry.entry_id)}, + manufacturer="Google", + model="Google Translate TTS", + translation_key="google_translate", + translation_placeholders={"lang": self._lang, "tld": self._tld}, + ) + self._attr_default_language = self._lang def get_tts_audio( self, message: str, language: str, options: dict[str, Any] | None = None diff --git a/homeassistant/components/google_weather/coordinator.py b/homeassistant/components/google_weather/coordinator.py index 3f81a8a31e9be4..695dc5ea19128a 100644 --- a/homeassistant/components/google_weather/coordinator.py +++ b/homeassistant/components/google_weather/coordinator.py @@ -24,6 +24,8 @@ UpdateFailed, ) +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) T = TypeVar( @@ -97,7 +99,13 @@ async def _async_update_data(self) -> T: self.subentry.title, err, ) - raise UpdateFailed(f"Error fetching {self._data_type_name}") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={ + "error": str(err), + }, + ) from err class GoogleWeatherCurrentConditionsCoordinator( diff --git a/homeassistant/components/google_weather/quality_scale.yaml b/homeassistant/components/google_weather/quality_scale.yaml index 946bcc9a0d3a66..8c86565e8f94d9 100644 --- a/homeassistant/components/google_weather/quality_scale.yaml +++ b/homeassistant/components/google_weather/quality_scale.yaml @@ -66,7 +66,7 @@ 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 repair-issues: diff --git a/homeassistant/components/google_weather/strings.json b/homeassistant/components/google_weather/strings.json index 7f23f297544690..977adb306fc021 100644 --- a/homeassistant/components/google_weather/strings.json +++ b/homeassistant/components/google_weather/strings.json @@ -98,5 +98,10 @@ "name": "Wind gust speed" } } + }, + "exceptions": { + "update_error": { + "message": "Error fetching weather data: {error}" + } } } diff --git a/homeassistant/components/govee_ble/manifest.json b/homeassistant/components/govee_ble/manifest.json index 696194266f478c..ed1518be6cc1b6 100644 --- a/homeassistant/components/govee_ble/manifest.json +++ b/homeassistant/components/govee_ble/manifest.json @@ -54,6 +54,10 @@ "connectable": false, "local_name": "GVH5110*" }, + { + "connectable": false, + "local_name": "GV5140*" + }, { "connectable": false, "manufacturer_id": 1, @@ -140,5 +144,5 @@ "documentation": "https://www.home-assistant.io/integrations/govee_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["govee-ble==0.44.0"] + "requirements": ["govee-ble==1.2.0"] } diff --git a/homeassistant/components/govee_ble/sensor.py b/homeassistant/components/govee_ble/sensor.py index fa0b828176c86e..848268ae61fb3b 100644 --- a/homeassistant/components/govee_ble/sensor.py +++ b/homeassistant/components/govee_ble/sensor.py @@ -21,6 +21,7 @@ ) from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, UnitOfTemperature, @@ -72,6 +73,12 @@ native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ), + (DeviceClass.CO2, Units.CONCENTRATION_PARTS_PER_MILLION): SensorEntityDescription( + key=f"{DeviceClass.CO2}_{Units.CONCENTRATION_PARTS_PER_MILLION}", + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + ), } diff --git a/homeassistant/components/govee_light_local/__init__.py b/homeassistant/components/govee_light_local/__init__.py index 4315f5d5363d8e..509a8c0137f8e3 100644 --- a/homeassistant/components/govee_light_local/__init__.py +++ b/homeassistant/components/govee_light_local/__init__.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DISCOVERY_TIMEOUT +from .const import DISCOVERY_TIMEOUT, DOMAIN from .coordinator import GoveeLocalApiCoordinator, GoveeLocalConfigEntry PLATFORMS: list[Platform] = [Platform.LIGHT] @@ -52,7 +52,11 @@ async def await_cleanup(): _LOGGER.error("Start failed, errno: %d", ex.errno) return False _LOGGER.error("Port %s already in use", LISTENING_PORT) - raise ConfigEntryNotReady from ex + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="port_in_use", + translation_placeholders={"port": LISTENING_PORT}, + ) from ex await coordinator.async_config_entry_first_refresh() @@ -61,7 +65,9 @@ async def await_cleanup(): while not coordinator.devices: await asyncio.sleep(delay=1) except TimeoutError as ex: - raise ConfigEntryNotReady from ex + raise ConfigEntryNotReady( + translation_domain=DOMAIN, translation_key="no_devices_found" + ) from ex entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/govee_light_local/manifest.json b/homeassistant/components/govee_light_local/manifest.json index bdfd0f446dac46..992dbe7cf72715 100644 --- a/homeassistant/components/govee_light_local/manifest.json +++ b/homeassistant/components/govee_light_local/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/govee_light_local", "iot_class": "local_push", - "requirements": ["govee-local-api==2.3.0"] + "requirements": ["govee-local-api==2.4.0"] } diff --git a/homeassistant/components/govee_light_local/strings.json b/homeassistant/components/govee_light_local/strings.json index 15140f174dc036..afa664d1ae04a8 100644 --- a/homeassistant/components/govee_light_local/strings.json +++ b/homeassistant/components/govee_light_local/strings.json @@ -33,5 +33,13 @@ } } } + }, + "exceptions": { + "no_devices_found": { + "message": "[%key:common::config_flow::abort::no_devices_found%]" + }, + "port_in_use": { + "message": "Port {port} is already in use" + } } } diff --git a/homeassistant/components/gpslogger/strings.json b/homeassistant/components/gpslogger/strings.json index e6458c38007c79..19cf5ba5bb500b 100644 --- a/homeassistant/components/gpslogger/strings.json +++ b/homeassistant/components/gpslogger/strings.json @@ -2,6 +2,7 @@ "config": { "abort": { "cloud_not_connected": "[%key:common::config_flow::abort::cloud_not_connected%]", + "reconfigure_successful": "**Reconfiguration was successful**\n\nGo to the webhook feature in GPSLogger and update the webhook with the following settings:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details.", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]" }, @@ -9,6 +10,10 @@ "default": "To send events to Home Assistant, you will need to set up the webhook feature in GPSLogger.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details." }, "step": { + "reconfigure": { + "description": "Are you sure you want to reconfigure the GPSLogger webhook?", + "title": "Reconfigure GPSLogger webhook" + }, "user": { "description": "Are you sure you want to set up the GPSLogger webhook?", "title": "Set up the GPSLogger webhook" diff --git a/homeassistant/components/greenwave/light.py b/homeassistant/components/greenwave/light.py index 9b7a3cf29ea183..3512595b53ac62 100644 --- a/homeassistant/components/greenwave/light.py +++ b/homeassistant/components/greenwave/light.py @@ -74,18 +74,13 @@ def __init__(self, light, host, token, gatewaydata): """Initialize a Greenwave Reality Light.""" self._did = int(light["did"]) self._attr_name = light["name"] - self._state = int(light["state"]) + self._attr_is_on = bool(int(light["state"])) self._attr_brightness = greenwave.hass_brightness(light) self._host = host self._attr_available = greenwave.check_online(light) self._token = token self._gatewaydata = gatewaydata - @property - def is_on(self): - """Return true if light is on.""" - return self._state - def turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" temp_brightness = int((kwargs.get(ATTR_BRIGHTNESS, 255) / 255) * 100) @@ -101,7 +96,7 @@ def update(self) -> None: self._gatewaydata.update() bulbs = self._gatewaydata.greenwave - self._state = int(bulbs[self._did]["state"]) + self._attr_is_on = bool(int(bulbs[self._did]["state"])) self._attr_brightness = greenwave.hass_brightness(bulbs[self._did]) self._attr_available = greenwave.check_online(bulbs[self._did]) self._attr_name = bulbs[self._did]["name"] diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 756e75ca22b4a7..5e199e5bcad48d 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -185,8 +185,7 @@ async def reload_service_handler(service: ServiceCall) -> None: - Remove group.group entities not created by service calls and set them up again - Reload xxx.group platforms """ - if (conf := await component.async_prepare_reload(skip_reset=True)) is None: - return + conf = await component.async_prepare_reload(skip_reset=True) # Simplified + modified version of EntityPlatform.async_reset: # - group.group never retries setup diff --git a/homeassistant/components/group/entity.py b/homeassistant/components/group/entity.py index f9d9a62a0ac7a1..4b44de708b5790 100644 --- a/homeassistant/components/group/entity.py +++ b/homeassistant/components/group/entity.py @@ -7,7 +7,13 @@ import logging from typing import Any -from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + ATTR_GROUP_ENTITIES, + STATE_OFF, + STATE_ON, +) from homeassistant.core import ( CALLBACK_TYPE, Event, @@ -35,7 +41,7 @@ class GroupEntity(Entity): """Representation of a Group of entities.""" - _unrecorded_attributes = frozenset({ATTR_ENTITY_ID}) + _unrecorded_attributes = frozenset({ATTR_ENTITY_ID, ATTR_GROUP_ENTITIES}) _attr_should_poll = False _entity_ids: list[str] diff --git a/homeassistant/components/group/lock.py b/homeassistant/components/group/lock.py index 7b460aa4632689..87e7474e03a1a2 100644 --- a/homeassistant/components/group/lock.py +++ b/homeassistant/components/group/lock.py @@ -20,9 +20,6 @@ CONF_ENTITIES, CONF_NAME, CONF_UNIQUE_ID, - SERVICE_LOCK, - SERVICE_OPEN, - SERVICE_UNLOCK, STATE_UNAVAILABLE, STATE_UNKNOWN, ) @@ -32,6 +29,7 @@ AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) +from homeassistant.helpers.group import GenericGroup from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .entity import GroupEntity @@ -117,47 +115,13 @@ def __init__( ) -> None: """Initialize a lock group.""" self._entity_ids = entity_ids + self.group = GenericGroup(self, entity_ids) self._attr_supported_features = LockEntityFeature.OPEN self._attr_name = name self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids} self._attr_unique_id = unique_id - async def async_lock(self, **kwargs: Any) -> None: - """Forward the lock command to all locks in the group.""" - data = {ATTR_ENTITY_ID: self._entity_ids} - _LOGGER.debug("Forwarded lock command: %s", data) - - await self.hass.services.async_call( - LOCK_DOMAIN, - SERVICE_LOCK, - data, - blocking=True, - context=self._context, - ) - - async def async_unlock(self, **kwargs: Any) -> None: - """Forward the unlock command to all locks in the group.""" - data = {ATTR_ENTITY_ID: self._entity_ids} - await self.hass.services.async_call( - LOCK_DOMAIN, - SERVICE_UNLOCK, - data, - blocking=True, - context=self._context, - ) - - async def async_open(self, **kwargs: Any) -> None: - """Forward the open command to all locks in the group.""" - data = {ATTR_ENTITY_ID: self._entity_ids} - await self.hass.services.async_call( - LOCK_DOMAIN, - SERVICE_OPEN, - data, - blocking=True, - context=self._context, - ) - @callback def async_update_group_state(self) -> None: """Query all members and determine the lock group state.""" diff --git a/homeassistant/components/growatt_server/__init__.py b/homeassistant/components/growatt_server/__init__.py index 43ca45920d174f..1833d914de6d79 100644 --- a/homeassistant/components/growatt_server/__init__.py +++ b/homeassistant/components/growatt_server/__init__.py @@ -1,4 +1,28 @@ -"""The Growatt server PV inverter sensor integration.""" +"""The Growatt server PV inverter sensor integration. + +This integration supports two distinct Growatt APIs with different auth models: + +Classic API (username/password): +- Authenticates via api.login(), which returns a dict with a "success" key. +- Auth failure is signalled by success=False and msg="502" (LOGIN_INVALID_AUTH_CODE). +- A failed login does NOT raise an exception — the return value must be checked. +- The coordinator calls api.login() on every update cycle to maintain the session. + +Open API V1 (API token): +- Stateless — no login call, token is sent as a Bearer header on every request. +- Auth failure is signalled by raising GrowattV1ApiError with error_code=10011 + (V1_API_ERROR_NO_PRIVILEGE). The library NEVER returns a failure silently; + any non-zero error_code raises an exception via _process_response(). +- Because the library always raises on error, return-value validation after a + successful V1 API call is unnecessary — if it returned, the token was valid. + +Error handling pattern for reauth: +- Classic API: check NOT login_response["success"] and msg == LOGIN_INVALID_AUTH_CODE + → raise ConfigEntryAuthFailed +- V1 API: catch GrowattV1ApiError with error_code == V1_API_ERROR_NO_PRIVILEGE + → raise ConfigEntryAuthFailed +- All other errors → ConfigEntryError (setup) or UpdateFailed (coordinator) +""" from collections.abc import Mapping from json import JSONDecodeError @@ -25,6 +49,7 @@ DOMAIN, LOGIN_INVALID_AUTH_CODE, PLATFORMS, + V1_API_ERROR_NO_PRIVILEGE, ) from .coordinator import GrowattConfigEntry, GrowattCoordinator from .models import GrowattRuntimeData @@ -214,6 +239,9 @@ def _login_classic_api( return login_response +V1_DEVICE_TYPES: dict[int, str] = {5: "sph", 7: "min"} + + def get_device_list_v1( api, config: Mapping[str, str] ) -> tuple[list[dict[str, str]], str]: @@ -227,22 +255,25 @@ def get_device_list_v1( try: devices_dict = api.device_list(plant_id) except growattServer.GrowattV1ApiError as e: + if e.error_code == V1_API_ERROR_NO_PRIVILEGE: + raise ConfigEntryAuthFailed( + f"Authentication failed for Growatt API: {e.error_msg or str(e)}" + ) from e raise ConfigEntryError( - f"API error during device list: {e} (Code: {getattr(e, 'error_code', None)}, Message: {getattr(e, 'error_msg', None)})" + f"API error during device list: {e.error_msg or str(e)} (Code: {e.error_code})" ) from e devices = devices_dict.get("devices", []) - # Only MIN device (type = 7) support implemented in current V1 API supported_devices = [ { "deviceSn": device.get("device_sn", ""), - "deviceType": "min", + "deviceType": V1_DEVICE_TYPES[device.get("type")], } for device in devices - if device.get("type") == 7 + if device.get("type") in V1_DEVICE_TYPES ] for device in devices: - if device.get("type") != 7: + if device.get("type") not in V1_DEVICE_TYPES: _LOGGER.warning( "Device %s with type %s not supported in Open API V1, skipping", device.get("device_sn", ""), @@ -272,6 +303,7 @@ async def async_setup_entry( # V1 API (token-based, no login needed) token = config[CONF_TOKEN] api = growattServer.OpenApiV1(token=token) + api.server_url = url devices, plant_id = await hass.async_add_executor_job( get_device_list_v1, api, config ) @@ -318,7 +350,7 @@ async def async_setup_entry( hass, config_entry, device["deviceSn"], device["deviceType"], plant_id ) for device in devices - if device["deviceType"] in ["inverter", "tlx", "storage", "mix", "min"] + if device["deviceType"] in ["inverter", "tlx", "storage", "mix", "min", "sph"] } # Perform the first refresh for the total coordinator diff --git a/homeassistant/components/growatt_server/config_flow.py b/homeassistant/components/growatt_server/config_flow.py index f1bb680904dcbf..bec7e583c26c2d 100644 --- a/homeassistant/components/growatt_server/config_flow.py +++ b/homeassistant/components/growatt_server/config_flow.py @@ -1,5 +1,6 @@ """Config flow for growatt server integration.""" +from collections.abc import Mapping import logging from typing import Any @@ -31,8 +32,11 @@ ERROR_INVALID_AUTH, LOGIN_INVALID_AUTH_CODE, SERVER_URLS_NAMES, + V1_API_ERROR_NO_PRIVILEGE, ) +_URL_TO_REGION = {v: k for k, v in SERVER_URLS_NAMES.items()} + _LOGGER = logging.getLogger(__name__) @@ -60,6 +64,137 @@ async def async_step_user( menu_options=["password_auth", "token_auth"], ) + async def async_step_reauth(self, _: Mapping[str, Any]) -> ConfigFlowResult: + """Handle reauth.""" + 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.""" + 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) + + if auth_type == AUTH_PASSWORD: + server_url = SERVER_URLS_NAMES[user_input[CONF_REGION]] + api = growattServer.GrowattApi( + add_random_user_id=True, + agent_identifier=user_input[CONF_USERNAME], + ) + api.server_url = server_url + + try: + login_response = await self.hass.async_add_executor_job( + 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) + errors["base"] = ERROR_CANNOT_CONNECT + except (ValueError, KeyError, TypeError, AttributeError) as ex: + _LOGGER.debug("Invalid response format during reauth login: %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, + data_updates={ + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_URL: server_url, + }, + ) + elif login_response.get("msg") == LOGIN_INVALID_AUTH_CODE: + errors["base"] = ERROR_INVALID_AUTH + else: + errors["base"] = ERROR_CANNOT_CONNECT + + elif auth_type == AUTH_API_TOKEN: + server_url = SERVER_URLS_NAMES[user_input[CONF_REGION]] + api = growattServer.OpenApiV1(token=user_input[CONF_TOKEN]) + api.server_url = server_url + + 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 + ) + 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)", + 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 + ) + errors["base"] = ERROR_CANNOT_CONNECT + else: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates={ + CONF_TOKEN: user_input[CONF_TOKEN], + CONF_URL: server_url, + }, + ) + + # 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, "") + 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) + if auth_type == AUTH_PASSWORD: + data_schema = vol.Schema( + { + vol.Required( + CONF_USERNAME, + default=reauth_entry.data.get(CONF_USERNAME), + ): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_REGION, default=current_region): SelectSelector( + SelectSelectorConfig( + options=list(SERVER_URLS_NAMES.keys()), + translation_key="region", + ) + ), + } + ) + elif auth_type == AUTH_API_TOKEN: + data_schema = vol.Schema( + { + vol.Required(CONF_TOKEN): str, + vol.Required(CONF_REGION, default=current_region): SelectSelector( + SelectSelectorConfig( + options=list(SERVER_URLS_NAMES.keys()), + translation_key="region", + ) + ), + } + ) + else: + return self.async_abort(reason=ERROR_CANNOT_CONNECT) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=data_schema, + errors=errors, + ) + async def async_step_password_auth( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -129,9 +264,11 @@ async def async_step_token_auth( _LOGGER.error( "Growatt V1 API error: %s (Code: %s)", e.error_msg or str(e), - getattr(e, "error_code", None), + e.error_code, ) - return self._async_show_token_form({"base": ERROR_INVALID_AUTH}) + if e.error_code == V1_API_ERROR_NO_PRIVILEGE: + return self._async_show_token_form({"base": ERROR_INVALID_AUTH}) + return self._async_show_token_form({"base": ERROR_CANNOT_CONNECT}) except (ValueError, KeyError, TypeError, AttributeError) as ex: _LOGGER.error( "Invalid response format during Growatt V1 API plant list: %s", ex diff --git a/homeassistant/components/growatt_server/const.py b/homeassistant/components/growatt_server/const.py index ea874707db9bbc..555a5e30547987 100644 --- a/homeassistant/components/growatt_server/const.py +++ b/homeassistant/components/growatt_server/const.py @@ -40,8 +40,17 @@ PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] +# Growatt Classic API error codes LOGIN_INVALID_AUTH_CODE = "502" +# Growatt Open API V1 error codes +# Reference: https://www.showdoc.com.cn/262556420217021/1494055648380019 +V1_API_ERROR_WRONG_DOMAIN = -1 # Use correct regional domain +V1_API_ERROR_NO_PRIVILEGE = 10011 # No privilege access — invalid or expired token +V1_API_ERROR_RATE_LIMITED = 10012 # Access frequency limit (5 minutes per call) +V1_API_ERROR_PAGE_SIZE = 10013 # Page size cannot exceed 100 +V1_API_ERROR_PAGE_COUNT = 10014 # Page count cannot exceed 250 + # Config flow error types (also used as abort reasons) ERROR_CANNOT_CONNECT = "cannot_connect" # Used for both form errors and aborts ERROR_INVALID_AUTH = "invalid_auth" diff --git a/homeassistant/components/growatt_server/coordinator.py b/homeassistant/components/growatt_server/coordinator.py index 68297e9c1c72a5..7fc81e9975d246 100644 --- a/homeassistant/components/growatt_server/coordinator.py +++ b/homeassistant/components/growatt_server/coordinator.py @@ -9,10 +9,15 @@ import growattServer +from homeassistant.components.sensor import SensorStateClass from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + HomeAssistantError, + ServiceValidationError, +) from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -22,6 +27,8 @@ BATT_MODE_LOAD_FIRST, DEFAULT_URL, DOMAIN, + LOGIN_INVALID_AUTH_CODE, + V1_API_ERROR_NO_PRIVILEGE, ) from .models import GrowattRuntimeData @@ -54,6 +61,7 @@ def __init__( self.device_type = device_type self.plant_id = plant_id self.previous_values: dict[str, Any] = {} + self._pre_reset_values: dict[str, float] = {} if self.api_version == "v1": self.username = None @@ -61,6 +69,7 @@ def __init__( self.url = config_entry.data.get(CONF_URL, DEFAULT_URL) self.token = config_entry.data["token"] self.api = growattServer.OpenApiV1(token=self.token) + self.api.server_url = self.url elif self.api_version == "classic": self.username = config_entry.data.get(CONF_USERNAME) self.password = config_entry.data[CONF_PASSWORD] @@ -86,7 +95,14 @@ def _sync_update_data(self) -> dict[str, Any]: # login only required for classic API if self.api_version == "classic": - self.api.login(self.username, self.password) + login_response = self.api.login(self.username, self.password) + if not login_response.get("success"): + msg = login_response.get("msg", "Unknown error") + if msg == LOGIN_INVALID_AUTH_CODE: + raise ConfigEntryAuthFailed( + "Username, password, or URL may be incorrect" + ) + raise UpdateFailed(f"Growatt login failed: {msg}") if self.device_type == "total": if self.api_version == "v1": @@ -98,7 +114,16 @@ def _sync_update_data(self) -> dict[str, Any]: # todayEnergy -> today_energy # totalEnergy -> total_energy # invTodayPpv -> current_power - total_info = self.api.plant_energy_overview(self.plant_id) + try: + total_info = self.api.plant_energy_overview(self.plant_id) + except growattServer.GrowattV1ApiError as err: + if err.error_code == V1_API_ERROR_NO_PRIVILEGE: + raise ConfigEntryAuthFailed( + f"Authentication failed for Growatt API: {err.error_msg or str(err)}" + ) from err + raise UpdateFailed( + f"Error fetching plant energy overview: {err}" + ) from err total_info["todayEnergy"] = total_info["today_energy"] total_info["totalEnergy"] = total_info["total_energy"] total_info["invTodayPpv"] = total_info["current_power"] @@ -120,6 +145,10 @@ def _sync_update_data(self) -> dict[str, Any]: min_settings = self.api.min_settings(self.device_id) min_energy = self.api.min_energy(self.device_id) except growattServer.GrowattV1ApiError as err: + if err.error_code == V1_API_ERROR_NO_PRIVILEGE: + raise ConfigEntryAuthFailed( + f"Authentication failed for Growatt API: {err.error_msg or str(err)}" + ) from err raise UpdateFailed(f"Error fetching min device data: {err}") from err min_info = {**min_details, **min_settings, **min_energy} @@ -138,6 +167,36 @@ def _sync_update_data(self) -> dict[str, Any]: **storage_info_detail["storageDetailBean"], **storage_energy_overview, } + elif self.device_type == "sph": + try: + sph_detail = self.api.sph_detail(self.device_id) + sph_energy = self.api.sph_energy(self.device_id) + except growattServer.GrowattV1ApiError as err: + if err.error_code == V1_API_ERROR_NO_PRIVILEGE: + raise ConfigEntryAuthFailed( + f"Authentication failed for Growatt API: {err.error_msg or str(err)}" + ) from err + raise UpdateFailed(f"Error fetching SPH device data: {err}") from err + + combined = {**sph_detail, **sph_energy} + + # Parse last update timestamp from sph_energy "time" field + time_str = sph_energy.get("time") + if time_str: + try: + parsed = datetime.datetime.strptime(time_str, "%Y-%m-%d %H:%M:%S") + combined["lastdataupdate"] = parsed.replace( + tzinfo=dt_util.get_default_time_zone() + ) + except ValueError, TypeError: + _LOGGER.debug( + "Could not parse SPH time field for %s: %r", + self.device_id, + time_str, + ) + + self.data = combined + _LOGGER.debug("sph_info for device %s: %r", self.device_id, self.data) elif self.device_type == "mix": mix_info = self.api.mix_info(self.device_id) mix_totals = self.api.mix_totals(self.device_id, self.plant_id) @@ -192,7 +251,7 @@ def get_currency(self): def get_data( self, entity_description: GrowattSensorEntityDescription - ) -> str | int | float | None: + ) -> str | int | float | datetime.datetime | datetime.date | None: """Get the data.""" variable = entity_description.api_key api_value = self.data.get(variable) @@ -251,6 +310,40 @@ def get_data( ) return_value = previous_value + # Suppress midnight bounce for TOTAL_INCREASING "today" sensors. + # The Growatt API sometimes delivers stale yesterday values after a midnight + # reset (0 → stale → 0), causing TOTAL_INCREASING double-counting. + if ( + entity_description.state_class is SensorStateClass.TOTAL_INCREASING + and not entity_description.never_resets + and return_value is not None + and previous_value is not None + ): + current_val = float(return_value) + prev_val = float(previous_value) + if prev_val > 0 and current_val == 0: + # Value dropped to 0 from a positive level — track it. + self._pre_reset_values[variable] = prev_val + elif variable in self._pre_reset_values: + pre_reset = self._pre_reset_values[variable] + if current_val == pre_reset: + # Value equals yesterday's final value — the API is + # serving a stale cached response (bounce) + _LOGGER.debug( + "Suppressing midnight bounce for %s: stale value %s matches " + "pre-reset value, keeping %s", + variable, + current_val, + previous_value, + ) + return_value = previous_value + elif current_val > 0: + # Genuine new-day production — clear tracking + del self._pre_reset_values[variable] + + # Note: previous_values stores the *output* value (after suppression), + # not the raw API value. This is intentional — after a suppressed bounce, + # previous_value will be 0, which is what downstream comparisons need. self.previous_values[variable] = return_value return return_value @@ -279,7 +372,8 @@ async def update_time_segment( if self.api_version != "v1": raise ServiceValidationError( - "Updating time segments requires token authentication" + translation_domain=DOMAIN, + translation_key="token_auth_required", ) try: @@ -295,7 +389,11 @@ async def update_time_segment( enabled, ) except growattServer.GrowattV1ApiError as err: - raise HomeAssistantError(f"API error updating time segment: {err}") from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={"error": str(err)}, + ) from err # Update coordinator's cached data without making an API call (avoids rate limit) if self.data: @@ -318,7 +416,8 @@ async def read_time_segments(self) -> list[dict]: if self.api_version != "v1": raise ServiceValidationError( - "Reading time segments requires token authentication" + translation_domain=DOMAIN, + translation_key="token_auth_required", ) # Ensure we have current data @@ -385,3 +484,131 @@ def _format_time(self, time_raw: str) -> str: return "00:00" else: return f"{hour:02d}:{minute:02d}" + + async def update_ac_charge_times( + self, + charge_power: int, + charge_stop_soc: int, + mains_enabled: bool, + periods: list[dict], + ) -> None: + """Update AC charge time periods for SPH device. + + Args: + charge_power: Charge power limit (0-100 %) + charge_stop_soc: Stop charging at this SOC level (0-100 %) + mains_enabled: Whether AC (mains) charging is enabled + periods: List of up to 3 dicts with keys start_time, end_time, enabled + """ + if self.api_version != "v1": + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="token_auth_required", + ) + + try: + await self.hass.async_add_executor_job( + self.api.sph_write_ac_charge_times, + self.device_id, + charge_power, + charge_stop_soc, + mains_enabled, + periods, + ) + except growattServer.GrowattV1ApiError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={"error": str(err)}, + ) from err + + if self.data: + self.data["chargePowerCommand"] = charge_power + self.data["wchargeSOCLowLimit"] = charge_stop_soc + self.data["acChargeEnable"] = 1 if mains_enabled else 0 + for i, period in enumerate(periods, 1): + self.data[f"forcedChargeTimeStart{i}"] = period["start_time"].strftime( + "%H:%M" + ) + self.data[f"forcedChargeTimeStop{i}"] = period["end_time"].strftime( + "%H:%M" + ) + self.data[f"forcedChargeStopSwitch{i}"] = ( + 1 if period.get("enabled", False) else 0 + ) + self.async_set_updated_data(self.data) + + async def update_ac_discharge_times( + self, + discharge_power: int, + discharge_stop_soc: int, + periods: list[dict], + ) -> None: + """Update AC discharge time periods for SPH device. + + Args: + discharge_power: Discharge power limit (0-100 %) + discharge_stop_soc: Stop discharging at this SOC level (0-100 %) + periods: List of up to 3 dicts with keys start_time, end_time, enabled + """ + if self.api_version != "v1": + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="token_auth_required", + ) + + try: + await self.hass.async_add_executor_job( + self.api.sph_write_ac_discharge_times, + self.device_id, + discharge_power, + discharge_stop_soc, + periods, + ) + except growattServer.GrowattV1ApiError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={"error": str(err)}, + ) from err + + if self.data: + self.data["disChargePowerCommand"] = discharge_power + self.data["wdisChargeSOCLowLimit"] = discharge_stop_soc + for i, period in enumerate(periods, 1): + self.data[f"forcedDischargeTimeStart{i}"] = period[ + "start_time" + ].strftime("%H:%M") + self.data[f"forcedDischargeTimeStop{i}"] = period["end_time"].strftime( + "%H:%M" + ) + self.data[f"forcedDischargeStopSwitch{i}"] = ( + 1 if period.get("enabled", False) else 0 + ) + self.async_set_updated_data(self.data) + + async def read_ac_charge_times(self) -> dict: + """Read AC charge time settings from SPH device cache.""" + if self.api_version != "v1": + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="token_auth_required", + ) + + if not self.data: + await self.async_refresh() + + return self.api.sph_read_ac_charge_times(settings_data=self.data) + + async def read_ac_discharge_times(self) -> dict: + """Read AC discharge time settings from SPH device cache.""" + if self.api_version != "v1": + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="token_auth_required", + ) + + if not self.data: + await self.async_refresh() + + return self.api.sph_read_ac_discharge_times(settings_data=self.data) diff --git a/homeassistant/components/growatt_server/diagnostics.py b/homeassistant/components/growatt_server/diagnostics.py new file mode 100644 index 00000000000000..210712220c90e9 --- /dev/null +++ b/homeassistant/components/growatt_server/diagnostics.py @@ -0,0 +1,65 @@ +"""Diagnostics support for Growatt Server.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_UNIQUE_ID, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .const import CONF_PLANT_ID +from .coordinator import GrowattConfigEntry + +TO_REDACT = { + CONF_PASSWORD, + CONF_TOKEN, + CONF_USERNAME, + CONF_UNIQUE_ID, + CONF_PLANT_ID, + "user_id", + "deviceSn", + "device_sn", +} + +# Allowlist of safe telemetry fields from the total coordinator. +# Monetary fields (plantMoneyText, totalMoneyText, currency) are intentionally +# excluded to avoid leaking financial data under unpredictable key names. +_TOTAL_SAFE_KEYS = frozenset( + { + # Classic API keys + "todayEnergy", + "totalEnergy", + "invTodayPpv", + "nominalPower", + # V1 API keys (aliases used after normalisation in coordinator) + "today_energy", + "total_energy", + "current_power", + } +) + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: GrowattConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + runtime_data = config_entry.runtime_data + total_data = runtime_data.total_coordinator.data or {} + return async_redact_data( + { + "config_entry": config_entry.as_dict(), + "total_coordinator": { + k: v for k, v in total_data.items() if k in _TOTAL_SAFE_KEYS + }, + "devices": [ + { + "device_sn": device_sn, + "device_type": coordinator.device_type, + "data": coordinator.data, + } + for device_sn, coordinator in runtime_data.devices.items() + ], + }, + TO_REDACT, + ) diff --git a/homeassistant/components/growatt_server/icons.json b/homeassistant/components/growatt_server/icons.json index 091ab64276079f..cba6469a5cedf3 100644 --- a/homeassistant/components/growatt_server/icons.json +++ b/homeassistant/components/growatt_server/icons.json @@ -1,10 +1,35 @@ { + "entity": { + "sensor": { + "storage_load_consumption_solar_storage": { + "default": "mdi:lightning-bolt" + }, + "total_money_today": { + "default": "mdi:cash" + }, + "total_money_total": { + "default": "mdi:cash" + } + } + }, "services": { + "read_ac_charge_times": { + "service": "mdi:battery-clock-outline" + }, + "read_ac_discharge_times": { + "service": "mdi:battery-clock-outline" + }, "read_time_segments": { "service": "mdi:clock-outline" }, "update_time_segment": { "service": "mdi:clock-edit" + }, + "write_ac_charge_times": { + "service": "mdi:battery-clock" + }, + "write_ac_discharge_times": { + "service": "mdi:battery-clock" } } } diff --git a/homeassistant/components/growatt_server/manifest.json b/homeassistant/components/growatt_server/manifest.json index 11342d5b942248..b00983d7f2b60e 100644 --- a/homeassistant/components/growatt_server/manifest.json +++ b/homeassistant/components/growatt_server/manifest.json @@ -7,5 +7,6 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["growattServer"], + "quality_scale": "silver", "requirements": ["growattServer==1.9.0"] } diff --git a/homeassistant/components/growatt_server/number.py b/homeassistant/components/growatt_server/number.py index 7016c25cadb22b..90bba2ac6f5511 100644 --- a/homeassistant/components/growatt_server/number.py +++ b/homeassistant/components/growatt_server/number.py @@ -17,7 +17,6 @@ from .const import DOMAIN from .coordinator import GrowattConfigEntry, GrowattCoordinator -from .sensor.sensor_entity_description import GrowattRequiredKeysMixin _LOGGER = logging.getLogger(__name__) @@ -27,9 +26,10 @@ @dataclass(frozen=True, kw_only=True) -class GrowattNumberEntityDescription(NumberEntityDescription, GrowattRequiredKeysMixin): +class GrowattNumberEntityDescription(NumberEntityDescription): """Describes Growatt number entity.""" + api_key: str write_key: str | None = None # Parameter ID for writing (if different from api_key) @@ -68,15 +68,25 @@ class GrowattNumberEntityDescription(NumberEntityDescription, GrowattRequiredKey native_unit_of_measurement=PERCENTAGE, ), GrowattNumberEntityDescription( - key="battery_discharge_soc_limit", - translation_key="battery_discharge_soc_limit", - api_key="wdisChargeSOCLowLimit", # Key returned by V1 API + key="battery_discharge_soc_limit", # Keep original key to preserve unique_id + translation_key="battery_discharge_soc_limit_off_grid", + api_key="wdisChargeSOCLowLimit", # Key returned by V1 API (off-grid) write_key="discharge_stop_soc", # Key used to write parameter native_step=1, native_min_value=0, native_max_value=100, native_unit_of_measurement=PERCENTAGE, ), + GrowattNumberEntityDescription( + key="battery_discharge_soc_limit_on_grid", + translation_key="battery_discharge_soc_limit_on_grid", + api_key="onGridDischargeStopSOC", # Key returned by V1 API (on-grid) + write_key="on_grid_discharge_stop_soc", # Key used to write parameter + native_step=1, + native_min_value=0, + native_max_value=100, + native_unit_of_measurement=PERCENTAGE, + ), ) @@ -120,6 +130,7 @@ def __init__( identifiers={(DOMAIN, coordinator.device_id)}, manufacturer="Growatt", name=coordinator.device_id, + serial_number=coordinator.device_id, ) @property @@ -147,7 +158,11 @@ async def async_set_native_value(self, value: float) -> None: int_value, ) except GrowattV1ApiError as e: - raise HomeAssistantError(f"Error while setting parameter: {e}") from e + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={"error": str(e)}, + ) from e # If no exception was raised, the write was successful _LOGGER.debug( diff --git a/homeassistant/components/growatt_server/quality_scale.yaml b/homeassistant/components/growatt_server/quality_scale.yaml index 72c43b4a643143..48f5168eb4bc3b 100644 --- a/homeassistant/components/growatt_server/quality_scale.yaml +++ b/homeassistant/components/growatt_server/quality_scale.yaml @@ -5,9 +5,7 @@ rules: brands: done common-modules: done config-flow-test-coverage: done - config-flow: - status: todo - comment: data-descriptions missing + config-flow: done dependency-transparency: done docs-actions: done docs-high-level-description: done @@ -25,19 +23,17 @@ rules: action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done - docs-installation-parameters: todo + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold - devices: - status: todo - comment: Add serial_number field to DeviceInfo in sensor, number, and switch platforms using device_id/serial_id. - diagnostics: todo + devices: done + diagnostics: done discovery-update-info: todo discovery: todo docs-data-update: todo @@ -48,16 +44,12 @@ rules: docs-troubleshooting: todo docs-use-cases: todo dynamic-devices: todo - entity-category: - status: todo - comment: Add EntityCategory.DIAGNOSTIC to temperature and other diagnostic sensors. Merge GrowattRequiredKeysMixin into GrowattSensorEntityDescription using kw_only=True. - entity-device-class: - status: todo - comment: Replace custom precision field with suggested_display_precision to preserve full data granularity. - entity-disabled-by-default: todo - entity-translations: todo - exception-translations: todo - icon-translations: todo + 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: status: exempt diff --git a/homeassistant/components/growatt_server/sensor/__init__.py b/homeassistant/components/growatt_server/sensor/__init__.py index d4e76c8d868937..c52ff2515a5b47 100644 --- a/homeassistant/components/growatt_server/sensor/__init__.py +++ b/homeassistant/components/growatt_server/sensor/__init__.py @@ -2,12 +2,14 @@ from __future__ import annotations +from datetime import date, datetime import logging from homeassistant.components.sensor import SensorEntity 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 @@ -15,6 +17,7 @@ from .inverter import INVERTER_SENSOR_TYPES from .mix import MIX_SENSOR_TYPES from .sensor_entity_description import GrowattSensorEntityDescription +from .sph import SPH_SENSOR_TYPES from .storage import STORAGE_SENSOR_TYPES from .tlx import TLX_SENSOR_TYPES from .total import TOTAL_SENSOR_TYPES @@ -57,6 +60,8 @@ async def async_setup_entry( sensor_descriptions = list(STORAGE_SENSOR_TYPES) elif device_coordinator.device_type == "mix": sensor_descriptions = list(MIX_SENSOR_TYPES) + elif device_coordinator.device_type == "sph": + sensor_descriptions = list(SPH_SENSOR_TYPES) else: _LOGGER.debug( "Device type %s was found but is not supported right now", @@ -96,24 +101,18 @@ def __init__( self.entity_description = description self._attr_unique_id = unique_id - self._attr_icon = "mdi:solar-power" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, serial_id)}, manufacturer="Growatt", name=name, + serial_number=serial_id, ) @property - def native_value(self) -> str | int | float | None: + def native_value(self) -> StateType | date | datetime: """Return the state of the sensor.""" - result = self.coordinator.get_data(self.entity_description) - if ( - isinstance(result, (int, float)) - and self.entity_description.precision is not None - ): - result = round(result, self.entity_description.precision) - return result + return self.coordinator.get_data(self.entity_description) @property def native_unit_of_measurement(self) -> str | None: diff --git a/homeassistant/components/growatt_server/sensor/inverter.py b/homeassistant/components/growatt_server/sensor/inverter.py index 99c0256a6cdf98..dcefc394b87074 100644 --- a/homeassistant/components/growatt_server/sensor/inverter.py +++ b/homeassistant/components/growatt_server/sensor/inverter.py @@ -4,6 +4,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import ( + EntityCategory, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, @@ -22,7 +23,7 @@ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="inverter_energy_total", @@ -30,7 +31,7 @@ api_key="powerTotal", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - precision=1, + suggested_display_precision=1, state_class=SensorStateClass.TOTAL, ), GrowattSensorEntityDescription( @@ -40,7 +41,7 @@ native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, - precision=2, + suggested_display_precision=2, ), GrowattSensorEntityDescription( key="inverter_amperage_input_1", @@ -49,7 +50,7 @@ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="inverter_wattage_input_1", @@ -58,7 +59,7 @@ native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="inverter_voltage_input_2", @@ -67,7 +68,7 @@ native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="inverter_amperage_input_2", @@ -76,7 +77,7 @@ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="inverter_wattage_input_2", @@ -85,7 +86,7 @@ native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="inverter_voltage_input_3", @@ -94,7 +95,7 @@ native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="inverter_amperage_input_3", @@ -103,7 +104,7 @@ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="inverter_wattage_input_3", @@ -112,7 +113,7 @@ native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="inverter_internal_wattage", @@ -121,7 +122,7 @@ native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="inverter_reactive_voltage", @@ -130,7 +131,9 @@ native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, - precision=1, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), GrowattSensorEntityDescription( key="inverter_inverter_reactive_amperage", @@ -139,7 +142,9 @@ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, - precision=1, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), GrowattSensorEntityDescription( key="inverter_frequency", @@ -148,7 +153,9 @@ native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, - precision=1, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), GrowattSensorEntityDescription( key="inverter_current_wattage", @@ -157,7 +164,7 @@ native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="inverter_current_reactive_wattage", @@ -166,7 +173,9 @@ native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, - precision=1, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), GrowattSensorEntityDescription( key="inverter_ipm_temperature", @@ -175,7 +184,9 @@ native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - precision=1, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), GrowattSensorEntityDescription( key="inverter_temperature", @@ -184,6 +195,8 @@ native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - precision=1, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), ) diff --git a/homeassistant/components/growatt_server/sensor/sensor_entity_description.py b/homeassistant/components/growatt_server/sensor/sensor_entity_description.py index e1ee4c303267a9..e1bb01c5d8482c 100644 --- a/homeassistant/components/growatt_server/sensor/sensor_entity_description.py +++ b/homeassistant/components/growatt_server/sensor/sensor_entity_description.py @@ -7,18 +7,11 @@ from homeassistant.components.sensor import SensorEntityDescription -@dataclass(frozen=True) -class GrowattRequiredKeysMixin: - """Mixin for required keys.""" - - api_key: str - - -@dataclass(frozen=True) -class GrowattSensorEntityDescription(SensorEntityDescription, GrowattRequiredKeysMixin): +@dataclass(frozen=True, kw_only=True) +class GrowattSensorEntityDescription(SensorEntityDescription): """Describes Growatt sensor entity.""" - precision: int | None = None + api_key: str currency: bool = False previous_value_drop_threshold: float | None = None never_resets: bool = False diff --git a/homeassistant/components/growatt_server/sensor/sph.py b/homeassistant/components/growatt_server/sensor/sph.py new file mode 100644 index 00000000000000..af3e05da57a8db --- /dev/null +++ b/homeassistant/components/growatt_server/sensor/sph.py @@ -0,0 +1,304 @@ +"""Growatt Sensor definitions for the SPH type.""" + +from __future__ import annotations + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfFrequency, + UnitOfPower, + UnitOfTemperature, +) + +from .sensor_entity_description import GrowattSensorEntityDescription + +SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( + # Values from 'sph_detail' API call + GrowattSensorEntityDescription( + key="mix_statement_of_charge", + translation_key="mix_statement_of_charge", + api_key="bmsSOC", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + ), + GrowattSensorEntityDescription( + key="mix_battery_voltage", + translation_key="mix_battery_voltage", + api_key="vbat", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + ), + GrowattSensorEntityDescription( + key="mix_pv1_voltage", + translation_key="mix_pv1_voltage", + api_key="vpv1", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + ), + GrowattSensorEntityDescription( + key="mix_pv2_voltage", + translation_key="mix_pv2_voltage", + api_key="vpv2", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + ), + GrowattSensorEntityDescription( + key="mix_grid_voltage", + translation_key="mix_grid_voltage", + api_key="vac1", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + ), + GrowattSensorEntityDescription( + key="mix_battery_charge", + translation_key="mix_battery_charge", + api_key="pcharge1", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + GrowattSensorEntityDescription( + key="mix_battery_discharge_w", + translation_key="mix_battery_discharge_w", + api_key="pdischarge1", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + GrowattSensorEntityDescription( + key="mix_export_to_grid", + translation_key="mix_export_to_grid", + api_key="pacToGridTotal", + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + GrowattSensorEntityDescription( + key="mix_import_from_grid", + translation_key="mix_import_from_grid", + api_key="pacToUserR", + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + GrowattSensorEntityDescription( + key="sph_grid_frequency", + translation_key="sph_grid_frequency", + api_key="fac", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + GrowattSensorEntityDescription( + key="sph_temperature_1", + translation_key="sph_temperature_1", + api_key="temp1", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + GrowattSensorEntityDescription( + key="sph_temperature_2", + translation_key="sph_temperature_2", + api_key="temp2", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + GrowattSensorEntityDescription( + key="sph_temperature_3", + translation_key="sph_temperature_3", + api_key="temp3", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + GrowattSensorEntityDescription( + key="sph_temperature_4", + translation_key="sph_temperature_4", + api_key="temp4", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + GrowattSensorEntityDescription( + key="sph_temperature_5", + translation_key="sph_temperature_5", + api_key="temp5", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + # Values from 'sph_energy' API call + GrowattSensorEntityDescription( + key="mix_wattage_pv_1", + translation_key="mix_wattage_pv_1", + api_key="ppv1", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + GrowattSensorEntityDescription( + key="mix_wattage_pv_2", + translation_key="mix_wattage_pv_2", + api_key="ppv2", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + GrowattSensorEntityDescription( + key="mix_wattage_pv_all", + translation_key="mix_wattage_pv_all", + api_key="ppv", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + GrowattSensorEntityDescription( + key="mix_battery_charge_today", + translation_key="mix_battery_charge_today", + api_key="echarge1Today", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + GrowattSensorEntityDescription( + key="mix_battery_charge_lifetime", + translation_key="mix_battery_charge_lifetime", + api_key="echarge1Total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + never_resets=True, + ), + GrowattSensorEntityDescription( + key="mix_battery_discharge_today", + translation_key="mix_battery_discharge_today", + api_key="edischarge1Today", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + GrowattSensorEntityDescription( + key="mix_battery_discharge_lifetime", + translation_key="mix_battery_discharge_lifetime", + api_key="edischarge1Total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + never_resets=True, + ), + GrowattSensorEntityDescription( + key="mix_solar_generation_today", + translation_key="mix_solar_generation_today", + api_key="epvtoday", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + GrowattSensorEntityDescription( + key="mix_solar_generation_lifetime", + translation_key="mix_solar_generation_lifetime", + api_key="epvTotal", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + never_resets=True, + ), + GrowattSensorEntityDescription( + key="mix_system_production_today", + translation_key="mix_system_production_today", + api_key="esystemtoday", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + GrowattSensorEntityDescription( + key="mix_self_consumption_today", + translation_key="mix_self_consumption_today", + api_key="eselfToday", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + GrowattSensorEntityDescription( + key="mix_import_from_grid_today", + translation_key="mix_import_from_grid_today", + api_key="etoUserToday", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + GrowattSensorEntityDescription( + key="mix_export_to_grid_today", + translation_key="mix_export_to_grid_today", + api_key="etoGridToday", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + GrowattSensorEntityDescription( + key="mix_export_to_grid_lifetime", + translation_key="mix_export_to_grid_lifetime", + api_key="etogridTotal", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + never_resets=True, + ), + GrowattSensorEntityDescription( + key="mix_load_consumption_today", + translation_key="mix_load_consumption_today", + api_key="elocalLoadToday", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + GrowattSensorEntityDescription( + key="mix_load_consumption_lifetime", + translation_key="mix_load_consumption_lifetime", + api_key="elocalLoadTotal", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + never_resets=True, + ), + GrowattSensorEntityDescription( + key="mix_load_consumption_battery_today", + translation_key="mix_load_consumption_battery_today", + api_key="echarge1", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + GrowattSensorEntityDescription( + key="mix_load_consumption_solar_today", + translation_key="mix_load_consumption_solar_today", + api_key="eChargeToday", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + # Synthetic timestamp from 'time' field in sph_energy response + GrowattSensorEntityDescription( + key="mix_last_update", + translation_key="mix_last_update", + api_key="lastdataupdate", + device_class=SensorDeviceClass.TIMESTAMP, + ), +) diff --git a/homeassistant/components/growatt_server/sensor/storage.py b/homeassistant/components/growatt_server/sensor/storage.py index 66f67c82e84fc2..0ad3584ed460ad 100644 --- a/homeassistant/components/growatt_server/sensor/storage.py +++ b/homeassistant/components/growatt_server/sensor/storage.py @@ -5,6 +5,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import ( PERCENTAGE, + EntityCategory, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, @@ -189,7 +190,7 @@ native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, - precision=2, + suggested_display_precision=2, ), GrowattSensorEntityDescription( key="storage_pv_charging_voltage", @@ -198,7 +199,7 @@ native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, - precision=2, + suggested_display_precision=2, ), GrowattSensorEntityDescription( key="storage_pv_charging_voltage_2", @@ -207,7 +208,7 @@ native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, - precision=2, + suggested_display_precision=2, ), GrowattSensorEntityDescription( key="storage_ac_input_frequency_out", @@ -216,7 +217,9 @@ native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, - precision=2, + suggested_display_precision=2, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), GrowattSensorEntityDescription( key="storage_output_voltage", @@ -225,7 +228,7 @@ native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, - precision=2, + suggested_display_precision=2, ), GrowattSensorEntityDescription( key="storage_ac_output_frequency", @@ -234,7 +237,9 @@ native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, - precision=2, + suggested_display_precision=2, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), GrowattSensorEntityDescription( key="storage_current_PV", @@ -243,7 +248,7 @@ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, - precision=2, + suggested_display_precision=2, ), GrowattSensorEntityDescription( key="storage_current_1", @@ -252,7 +257,7 @@ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, - precision=2, + suggested_display_precision=2, ), GrowattSensorEntityDescription( key="storage_current_2", @@ -261,7 +266,7 @@ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, - precision=2, + suggested_display_precision=2, ), GrowattSensorEntityDescription( key="storage_grid_amperage_input", @@ -270,7 +275,7 @@ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, - precision=2, + suggested_display_precision=2, ), GrowattSensorEntityDescription( key="storage_grid_out_current", @@ -279,7 +284,7 @@ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, - precision=2, + suggested_display_precision=2, ), GrowattSensorEntityDescription( key="storage_battery_voltage", @@ -288,7 +293,7 @@ native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, - precision=2, + suggested_display_precision=2, ), GrowattSensorEntityDescription( key="storage_load_percentage", @@ -297,6 +302,6 @@ native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, - precision=2, + suggested_display_precision=2, ), ) diff --git a/homeassistant/components/growatt_server/sensor/tlx.py b/homeassistant/components/growatt_server/sensor/tlx.py index e3689fbf7d195f..7307ac87933bf9 100644 --- a/homeassistant/components/growatt_server/sensor/tlx.py +++ b/homeassistant/components/growatt_server/sensor/tlx.py @@ -8,6 +8,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import ( PERCENTAGE, + EntityCategory, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, @@ -26,7 +27,7 @@ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_energy_total", @@ -35,7 +36,7 @@ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - precision=1, + suggested_display_precision=1, never_resets=True, ), GrowattSensorEntityDescription( @@ -45,7 +46,7 @@ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - precision=1, + suggested_display_precision=1, never_resets=True, ), GrowattSensorEntityDescription( @@ -55,7 +56,7 @@ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_voltage_input_1", @@ -63,7 +64,7 @@ api_key="vpv1", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_amperage_input_1", @@ -71,7 +72,7 @@ api_key="ipv1", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_wattage_input_1", @@ -80,7 +81,7 @@ native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_energy_total_input_2", @@ -89,7 +90,7 @@ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - precision=1, + suggested_display_precision=1, never_resets=True, ), GrowattSensorEntityDescription( @@ -99,7 +100,7 @@ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_voltage_input_2", @@ -107,7 +108,7 @@ api_key="vpv2", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_amperage_input_2", @@ -115,7 +116,7 @@ api_key="ipv2", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_wattage_input_2", @@ -124,7 +125,7 @@ native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_energy_total_input_3", @@ -133,7 +134,7 @@ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - precision=1, + suggested_display_precision=1, never_resets=True, ), GrowattSensorEntityDescription( @@ -143,7 +144,7 @@ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_voltage_input_3", @@ -151,7 +152,7 @@ api_key="vpv3", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_amperage_input_3", @@ -159,7 +160,7 @@ api_key="ipv3", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_wattage_input_3", @@ -168,7 +169,7 @@ native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_energy_total_input_4", @@ -177,7 +178,7 @@ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - precision=1, + suggested_display_precision=1, never_resets=True, ), GrowattSensorEntityDescription( @@ -187,7 +188,7 @@ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_voltage_input_4", @@ -195,7 +196,7 @@ api_key="vpv4", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_amperage_input_4", @@ -203,7 +204,7 @@ api_key="ipv4", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_wattage_input_4", @@ -212,7 +213,7 @@ native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_solar_generation_today", @@ -221,7 +222,7 @@ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_solar_generation_total", @@ -239,7 +240,7 @@ native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_reactive_voltage", @@ -247,7 +248,9 @@ api_key="vacrs", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, - precision=1, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), GrowattSensorEntityDescription( key="tlx_frequency", @@ -255,7 +258,9 @@ api_key="fac", native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, - precision=1, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), GrowattSensorEntityDescription( key="tlx_current_wattage", @@ -264,7 +269,7 @@ native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_temperature_1", @@ -272,7 +277,9 @@ api_key="temp1", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, - precision=1, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), GrowattSensorEntityDescription( key="tlx_temperature_2", @@ -280,7 +287,9 @@ api_key="temp2", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, - precision=1, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), GrowattSensorEntityDescription( key="tlx_temperature_3", @@ -288,7 +297,9 @@ api_key="temp3", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, - precision=1, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), GrowattSensorEntityDescription( key="tlx_temperature_4", @@ -296,7 +307,9 @@ api_key="temp4", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, - precision=1, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), GrowattSensorEntityDescription( key="tlx_temperature_5", @@ -304,7 +317,9 @@ api_key="temp5", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, - precision=1, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), GrowattSensorEntityDescription( key="tlx_all_batteries_discharge_today", @@ -456,7 +471,7 @@ native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_pac_to_user_total", @@ -465,7 +480,7 @@ native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_pac_to_grid_total", @@ -474,7 +489,7 @@ native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_system_production_today", @@ -483,7 +498,7 @@ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_system_production_total", @@ -493,7 +508,7 @@ device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, never_resets=True, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_self_consumption_today", @@ -502,7 +517,7 @@ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_self_consumption_total", @@ -512,7 +527,7 @@ device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, never_resets=True, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_import_from_grid_today", @@ -521,7 +536,7 @@ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_import_from_grid_total", @@ -531,7 +546,7 @@ device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, never_resets=True, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_batteries_charged_from_grid_today", @@ -540,7 +555,7 @@ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_batteries_charged_from_grid_total", @@ -550,7 +565,7 @@ device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, never_resets=True, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_p_system", @@ -559,7 +574,7 @@ native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, - precision=1, + suggested_display_precision=1, ), GrowattSensorEntityDescription( key="tlx_p_self", @@ -568,6 +583,6 @@ native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, - precision=1, + suggested_display_precision=1, ), ) diff --git a/homeassistant/components/growatt_server/services.py b/homeassistant/components/growatt_server/services.py index 49cca1e80ff122..bebab342a04b52 100644 --- a/homeassistant/components/growatt_server/services.py +++ b/homeassistant/components/growatt_server/services.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime +from datetime import datetime, time from typing import TYPE_CHECKING, Any from homeassistant.config_entries import ConfigEntryState @@ -21,66 +21,94 @@ from .coordinator import GrowattCoordinator -@callback -def async_setup_services(hass: HomeAssistant) -> None: - """Register services for Growatt Server integration.""" +def _get_coordinators( + hass: HomeAssistant, device_type: str +) -> dict[str, GrowattCoordinator]: + """Get all coordinators of a given device type with V1 API.""" + coordinators: dict[str, GrowattCoordinator] = {} - def get_min_coordinators() -> dict[str, GrowattCoordinator]: - """Get all MIN coordinators with V1 API from loaded config entries.""" - min_coordinators: dict[str, GrowattCoordinator] = {} + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.state != ConfigEntryState.LOADED: + continue - for entry in hass.config_entries.async_entries(DOMAIN): - if entry.state != ConfigEntryState.LOADED: - continue + for coord in entry.runtime_data.devices.values(): + if coord.device_type == device_type and coord.api_version == "v1": + coordinators[coord.device_id] = coord - # Add MIN coordinators from this entry - for coord in entry.runtime_data.devices.values(): - if coord.device_type == "min" and coord.api_version == "v1": - min_coordinators[coord.device_id] = coord + return coordinators - return min_coordinators - def get_coordinator(device_id: str) -> GrowattCoordinator: - """Get coordinator by device_id. +def _get_coordinator( + hass: HomeAssistant, device_id: str, device_type: str +) -> GrowattCoordinator: + """Get coordinator by device registry ID and device type.""" + coordinators = _get_coordinators(hass, device_type) - Args: - device_id: Device registry ID (not serial number) - """ - # Get current coordinators (they may have changed since service registration) - min_coordinators = get_min_coordinators() + if not coordinators: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_devices_configured", + translation_placeholders={"device_type": device_type.upper()}, + ) - if not min_coordinators: - raise ServiceValidationError( - "No MIN devices with token authentication are configured. " - "Services require MIN devices with V1 API access." - ) + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(device_id) - # Device registry ID provided - map to serial number - device_registry = dr.async_get(hass) - device_entry = device_registry.async_get(device_id) + if not device_entry: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={"device_id": device_id}, + ) - if not device_entry: - raise ServiceValidationError(f"Device '{device_id}' not found") + serial_number = None + for identifier in device_entry.identifiers: + if identifier[0] == DOMAIN: + serial_number = identifier[1] + break + + if not serial_number: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="device_not_growatt", + translation_placeholders={"device_id": device_id}, + ) - # Extract serial number from device identifiers - serial_number = None - for identifier in device_entry.identifiers: - if identifier[0] == DOMAIN: - serial_number = identifier[1] - break + if serial_number not in coordinators: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="device_not_configured", + translation_placeholders={ + "device_type": device_type.upper(), + "serial_number": serial_number, + }, + ) - if not serial_number: - raise ServiceValidationError( - f"Device '{device_id}' is not a Growatt device" - ) + return coordinators[serial_number] - # Find coordinator by serial number - if serial_number not in min_coordinators: - raise ServiceValidationError( - f"MIN device '{serial_number}' not found or not configured for services" - ) - return min_coordinators[serial_number] +def _parse_time_str(time_str: str, field_name: str) -> time: + """Parse a time string (HH:MM or HH:MM:SS) to a datetime.time object.""" + parts = time_str.split(":") + if len(parts) not in (2, 3): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_time_format", + translation_placeholders={"field_name": field_name}, + ) + try: + return datetime.strptime(f"{parts[0]}:{parts[1]}", "%H:%M").time() + except (ValueError, IndexError) as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_time_format", + translation_placeholders={"field_name": field_name}, + ) from err + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register services for Growatt Server integration.""" async def handle_update_time_segment(call: ServiceCall) -> None: """Handle update_time_segment service call.""" @@ -91,13 +119,13 @@ async def handle_update_time_segment(call: ServiceCall) -> None: enabled: bool = call.data["enabled"] device_id: str = call.data["device_id"] - # Validate segment_id range if not 1 <= segment_id <= 9: raise ServiceValidationError( - f"segment_id must be between 1 and 9, got {segment_id}" + translation_domain=DOMAIN, + translation_key="invalid_segment_id", + translation_placeholders={"segment_id": str(segment_id)}, ) - # Validate and convert batt_mode string to integer valid_modes = { "load_first": BATT_MODE_LOAD_FIRST, "battery_first": BATT_MODE_BATTERY_FIRST, @@ -105,53 +133,137 @@ async def handle_update_time_segment(call: ServiceCall) -> None: } if batt_mode_str not in valid_modes: raise ServiceValidationError( - f"batt_mode must be one of {list(valid_modes.keys())}, got '{batt_mode_str}'" + translation_domain=DOMAIN, + translation_key="invalid_batt_mode", + translation_placeholders={ + "batt_mode": batt_mode_str, + "allowed_modes": ", ".join(valid_modes), + }, ) batt_mode: int = valid_modes[batt_mode_str] - # Convert time strings to datetime.time objects - # UI time selector sends HH:MM:SS, but we only need HH:MM (strip seconds) - try: - # Take only HH:MM part (ignore seconds if present) - start_parts = start_time_str.split(":") - start_time_hhmm = f"{start_parts[0]}:{start_parts[1]}" - start_time = datetime.strptime(start_time_hhmm, "%H:%M").time() - except (ValueError, IndexError) as err: - raise ServiceValidationError( - "start_time must be in HH:MM or HH:MM:SS format" - ) from err - - try: - # Take only HH:MM part (ignore seconds if present) - end_parts = end_time_str.split(":") - end_time_hhmm = f"{end_parts[0]}:{end_parts[1]}" - end_time = datetime.strptime(end_time_hhmm, "%H:%M").time() - except (ValueError, IndexError) as err: - raise ServiceValidationError( - "end_time must be in HH:MM or HH:MM:SS format" - ) from err - - # Get the appropriate MIN coordinator - coordinator: GrowattCoordinator = get_coordinator(device_id) + start_time = _parse_time_str(start_time_str, "start_time") + end_time = _parse_time_str(end_time_str, "end_time") + coordinator: GrowattCoordinator = _get_coordinator(hass, device_id, "min") await coordinator.update_time_segment( - segment_id, - batt_mode, - start_time, - end_time, - enabled, + segment_id, batt_mode, start_time, end_time, enabled ) async def handle_read_time_segments(call: ServiceCall) -> dict[str, Any]: """Handle read_time_segments service call.""" - device_id: str = call.data["device_id"] + coordinator: GrowattCoordinator = _get_coordinator( + hass, call.data["device_id"], "min" + ) + time_segments: list[dict[str, Any]] = await coordinator.read_time_segments() + return {"time_segments": time_segments} - # Get the appropriate MIN coordinator - coordinator: GrowattCoordinator = get_coordinator(device_id) + async def handle_write_ac_charge_times(call: ServiceCall) -> None: + """Handle write_ac_charge_times service call for SPH devices.""" + coordinator: GrowattCoordinator = _get_coordinator( + hass, call.data["device_id"], "sph" + ) + # Read current settings first — the SPH API requires all 3 periods in + # every write call. Any period not supplied by the caller is filled in + # from the cache so existing settings are not overwritten with zeros. + current = await coordinator.read_ac_charge_times() + + charge_power: int = int(call.data.get("charge_power", current["charge_power"])) + charge_stop_soc: int = int( + call.data.get("charge_stop_soc", current["charge_stop_soc"]) + ) + mains_enabled: bool = call.data.get("mains_enabled", current["mains_enabled"]) - time_segments: list[dict[str, Any]] = await coordinator.read_time_segments() + if not 0 <= charge_power <= 100: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_charge_power", + translation_placeholders={"value": str(charge_power)}, + ) + if not 0 <= charge_stop_soc <= 100: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_charge_stop_soc", + translation_placeholders={"value": str(charge_stop_soc)}, + ) - return {"time_segments": time_segments} + periods = [] + for i in range(1, 4): + cached = current["periods"][i - 1] + start = _parse_time_str( + call.data.get(f"period_{i}_start", cached["start_time"]), + f"period_{i}_start", + ) + end = _parse_time_str( + call.data.get(f"period_{i}_end", cached["end_time"]), + f"period_{i}_end", + ) + enabled: bool = call.data.get(f"period_{i}_enabled", cached["enabled"]) + periods.append({"start_time": start, "end_time": end, "enabled": enabled}) + + await coordinator.update_ac_charge_times( + charge_power, charge_stop_soc, mains_enabled, periods + ) + + async def handle_write_ac_discharge_times(call: ServiceCall) -> None: + """Handle write_ac_discharge_times service call for SPH devices.""" + coordinator: GrowattCoordinator = _get_coordinator( + hass, call.data["device_id"], "sph" + ) + # Read current settings first — same read-merge-write pattern as charge. + current = await coordinator.read_ac_discharge_times() + + discharge_power: int = int( + call.data.get("discharge_power", current["discharge_power"]) + ) + discharge_stop_soc: int = int( + call.data.get("discharge_stop_soc", current["discharge_stop_soc"]) + ) + + if not 0 <= discharge_power <= 100: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_discharge_power", + translation_placeholders={"value": str(discharge_power)}, + ) + if not 0 <= discharge_stop_soc <= 100: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_discharge_stop_soc", + translation_placeholders={"value": str(discharge_stop_soc)}, + ) + + periods = [] + for i in range(1, 4): + cached = current["periods"][i - 1] + start = _parse_time_str( + call.data.get(f"period_{i}_start", cached["start_time"]), + f"period_{i}_start", + ) + end = _parse_time_str( + call.data.get(f"period_{i}_end", cached["end_time"]), + f"period_{i}_end", + ) + enabled: bool = call.data.get(f"period_{i}_enabled", cached["enabled"]) + periods.append({"start_time": start, "end_time": end, "enabled": enabled}) + + await coordinator.update_ac_discharge_times( + discharge_power, discharge_stop_soc, periods + ) + + async def handle_read_ac_charge_times(call: ServiceCall) -> dict[str, Any]: + """Handle read_ac_charge_times service call for SPH devices.""" + coordinator: GrowattCoordinator = _get_coordinator( + hass, call.data["device_id"], "sph" + ) + return await coordinator.read_ac_charge_times() + + async def handle_read_ac_discharge_times(call: ServiceCall) -> dict[str, Any]: + """Handle read_ac_discharge_times service call for SPH devices.""" + coordinator: GrowattCoordinator = _get_coordinator( + hass, call.data["device_id"], "sph" + ) + return await coordinator.read_ac_discharge_times() # Register services without schema - services.yaml will provide UI definition # Schema validation happens in the handler functions @@ -168,3 +280,31 @@ async def handle_read_time_segments(call: ServiceCall) -> dict[str, Any]: handle_read_time_segments, supports_response=SupportsResponse.ONLY, ) + + hass.services.async_register( + DOMAIN, + "write_ac_charge_times", + handle_write_ac_charge_times, + supports_response=SupportsResponse.NONE, + ) + + hass.services.async_register( + DOMAIN, + "write_ac_discharge_times", + handle_write_ac_discharge_times, + supports_response=SupportsResponse.NONE, + ) + + hass.services.async_register( + DOMAIN, + "read_ac_charge_times", + handle_read_ac_charge_times, + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + "read_ac_discharge_times", + handle_read_ac_discharge_times, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/growatt_server/services.yaml b/homeassistant/components/growatt_server/services.yaml index 318ab71aad07f9..6d3f391193a159 100644 --- a/homeassistant/components/growatt_server/services.yaml +++ b/homeassistant/components/growatt_server/services.yaml @@ -48,3 +48,162 @@ read_time_segments: selector: device: integration: growatt_server + +write_ac_charge_times: + fields: + device_id: + required: true + selector: + device: + integration: growatt_server + charge_power: + required: false + example: 100 + selector: + number: + min: 0 + max: 100 + mode: slider + charge_stop_soc: + required: false + example: 100 + selector: + number: + min: 0 + max: 100 + mode: slider + mains_enabled: + required: false + example: true + selector: + boolean: + period_1_start: + required: false + example: "00:00" + selector: + time: + period_1_end: + required: false + example: "00:00" + selector: + time: + period_1_enabled: + required: false + example: false + selector: + boolean: + period_2_start: + required: false + example: "00:00" + selector: + time: + period_2_end: + required: false + example: "00:00" + selector: + time: + period_2_enabled: + required: false + example: false + selector: + boolean: + period_3_start: + required: false + example: "00:00" + selector: + time: + period_3_end: + required: false + example: "00:00" + selector: + time: + period_3_enabled: + required: false + example: false + selector: + boolean: + +write_ac_discharge_times: + fields: + device_id: + required: true + selector: + device: + integration: growatt_server + discharge_power: + required: false + example: 100 + selector: + number: + min: 0 + max: 100 + mode: slider + discharge_stop_soc: + required: false + example: 20 + selector: + number: + min: 0 + max: 100 + mode: slider + period_1_start: + required: false + example: "00:00" + selector: + time: + period_1_end: + required: false + example: "00:00" + selector: + time: + period_1_enabled: + required: false + example: false + selector: + boolean: + period_2_start: + required: false + example: "00:00" + selector: + time: + period_2_end: + required: false + example: "00:00" + selector: + time: + period_2_enabled: + required: false + example: false + selector: + boolean: + period_3_start: + required: false + example: "00:00" + selector: + time: + period_3_end: + required: false + example: "00:00" + selector: + time: + period_3_enabled: + required: false + example: false + selector: + boolean: + +read_ac_charge_times: + fields: + device_id: + required: true + selector: + device: + integration: growatt_server + +read_ac_discharge_times: + fields: + device_id: + required: true + selector: + device: + integration: growatt_server diff --git a/homeassistant/components/growatt_server/strings.json b/homeassistant/components/growatt_server/strings.json index ffb4654407934d..12322055da4456 100644 --- a/homeassistant/components/growatt_server/strings.json +++ b/homeassistant/components/growatt_server/strings.json @@ -3,7 +3,8 @@ "abort": { "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" + "no_plants": "No plants have been found on this account", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "cannot_connect": "Cannot connect to Growatt servers. Please check your internet connection and try again.", @@ -13,30 +14,58 @@ "password_auth": { "data": { "password": "[%key:common::config_flow::data::password%]", - "url": "Server region", + "region": "Server region", "username": "[%key:common::config_flow::data::username%]" }, + "data_description": { + "password": "The password for your Growatt account.", + "region": "The server region that matches your Growatt account location.", + "username": "The email address or username for your Growatt account." + }, "title": "Enter your Growatt login credentials" }, "plant": { "data": { "plant_id": "Plant" }, + "data_description": { + "plant_id": "The Growatt plant (solar installation) to integrate." + }, "title": "Select your plant" }, + "reauth_confirm": { + "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": "Re-enter your credentials to continue using this integration.", + "title": "Re-authenticate with Growatt" + }, "token_auth": { "data": { - "token": "API Token", - "url": "Server region" + "region": "[%key:component::growatt_server::config::step::password_auth::data::region%]", + "token": "API token" }, - "description": "Token authentication is only supported for MIN/TLX devices. For other device types, please use username/password authentication.", + "data_description": { + "region": "[%key:component::growatt_server::config::step::password_auth::data_description::region%]", + "token": "The API token for your Growatt account. You can generate one via the Growatt web portal or ShinePhone app." + }, + "description": "Token authentication is only supported for MIN/SPH devices. For other device types, please use username/password authentication.", "title": "Enter your API token" }, "user": { - "description": "Note: API Token authentication is currently only supported for MIN/TLX devices. For other device types, please use Username & Password authentication.", + "description": "Note: Token authentication is currently only supported for MIN/SPH devices. For other device types, please use username/password authentication.", "menu_options": { - "password_auth": "Username & Password", - "token_auth": "API Token (MIN/TLX only)" + "password_auth": "Username/password", + "token_auth": "API token (MIN/SPH only)" }, "title": "Choose authentication method" } @@ -53,8 +82,11 @@ "battery_discharge_power_limit": { "name": "Battery discharge power limit" }, - "battery_discharge_soc_limit": { - "name": "Battery discharge SOC limit" + "battery_discharge_soc_limit_off_grid": { + "name": "Battery discharge SOC limit (off-grid)" + }, + "battery_discharge_soc_limit_on_grid": { + "name": "Battery discharge SOC limit (on-grid)" } }, "sensor": { @@ -211,6 +243,24 @@ "mix_wattage_pv_all": { "name": "All PV wattage" }, + "sph_grid_frequency": { + "name": "AC frequency" + }, + "sph_temperature_1": { + "name": "Temperature 1" + }, + "sph_temperature_2": { + "name": "Temperature 2" + }, + "sph_temperature_3": { + "name": "Temperature 3" + }, + "sph_temperature_4": { + "name": "Temperature 4" + }, + "sph_temperature_5": { + "name": "Temperature 5" + }, "storage_ac_input_frequency_out": { "name": "AC input frequency" }, @@ -524,6 +574,47 @@ } } }, + "exceptions": { + "api_error": { + "message": "Growatt API error: {error}" + }, + "device_not_configured": { + "message": "{device_type} device {serial_number} is not configured for services." + }, + "device_not_found": { + "message": "Device {device_id} not found in the device registry." + }, + "device_not_growatt": { + "message": "Device {device_id} is not a Growatt device." + }, + "invalid_batt_mode": { + "message": "{batt_mode} is not a valid battery mode. Allowed values: {allowed_modes}." + }, + "invalid_charge_power": { + "message": "charge_power must be between 0 and 100, got {value}." + }, + "invalid_charge_stop_soc": { + "message": "charge_stop_soc must be between 0 and 100, got {value}." + }, + "invalid_discharge_power": { + "message": "discharge_power must be between 0 and 100, got {value}." + }, + "invalid_discharge_stop_soc": { + "message": "discharge_stop_soc must be between 0 and 100, got {value}." + }, + "invalid_segment_id": { + "message": "segment_id must be between 1 and 9, got {segment_id}." + }, + "invalid_time_format": { + "message": "{field_name} must be in HH:MM or HH:MM:SS format." + }, + "no_devices_configured": { + "message": "No {device_type} devices with token authentication are configured. Actions require {device_type} devices with V1 API access." + }, + "token_auth_required": { + "message": "This action requires token authentication (V1 API)." + } + }, "selector": { "batt_mode": { "options": { @@ -544,6 +635,26 @@ } }, "services": { + "read_ac_charge_times": { + "description": "Read AC charge time periods from an SPH device.", + "fields": { + "device_id": { + "description": "The Growatt SPH device to read from.", + "name": "Device" + } + }, + "name": "Read AC charge times" + }, + "read_ac_discharge_times": { + "description": "Read AC discharge time periods from an SPH device.", + "fields": { + "device_id": { + "description": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::description%]", + "name": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::name%]" + } + }, + "name": "Read AC discharge times" + }, "read_time_segments": { "description": "Read all time segments from a supported inverter.", "fields": { @@ -583,6 +694,118 @@ } }, "name": "Update time segment" + }, + "write_ac_charge_times": { + "description": "Write AC charge time periods to an SPH device.", + "fields": { + "charge_power": { + "description": "Charge power limit (%).", + "name": "Charge power" + }, + "charge_stop_soc": { + "description": "Stop charging at this state of charge (%).", + "name": "Charge stop SOC" + }, + "device_id": { + "description": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::description%]", + "name": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::name%]" + }, + "mains_enabled": { + "description": "Enable AC (mains) charging.", + "name": "Mains charging enabled" + }, + "period_1_enabled": { + "description": "Enable time period 1.", + "name": "Period 1 enabled" + }, + "period_1_end": { + "description": "End time for period 1 (HH:MM or HH:MM:SS).", + "name": "Period 1 end" + }, + "period_1_start": { + "description": "Start time for period 1 (HH:MM or HH:MM:SS).", + "name": "Period 1 start" + }, + "period_2_enabled": { + "description": "Enable time period 2.", + "name": "Period 2 enabled" + }, + "period_2_end": { + "description": "End time for period 2 (HH:MM or HH:MM:SS).", + "name": "Period 2 end" + }, + "period_2_start": { + "description": "Start time for period 2 (HH:MM or HH:MM:SS).", + "name": "Period 2 start" + }, + "period_3_enabled": { + "description": "Enable time period 3.", + "name": "Period 3 enabled" + }, + "period_3_end": { + "description": "End time for period 3 (HH:MM or HH:MM:SS).", + "name": "Period 3 end" + }, + "period_3_start": { + "description": "Start time for period 3 (HH:MM or HH:MM:SS).", + "name": "Period 3 start" + } + }, + "name": "Write AC charge times" + }, + "write_ac_discharge_times": { + "description": "Write AC discharge time periods to an SPH device.", + "fields": { + "device_id": { + "description": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::description%]", + "name": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::name%]" + }, + "discharge_power": { + "description": "Discharge power limit (%).", + "name": "Discharge power" + }, + "discharge_stop_soc": { + "description": "Stop discharging at this state of charge (%).", + "name": "Discharge stop SOC" + }, + "period_1_enabled": { + "description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_enabled::description%]", + "name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_enabled::name%]" + }, + "period_1_end": { + "description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_end::description%]", + "name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_end::name%]" + }, + "period_1_start": { + "description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_start::description%]", + "name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_start::name%]" + }, + "period_2_enabled": { + "description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_enabled::description%]", + "name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_enabled::name%]" + }, + "period_2_end": { + "description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_end::description%]", + "name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_end::name%]" + }, + "period_2_start": { + "description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_start::description%]", + "name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_start::name%]" + }, + "period_3_enabled": { + "description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_enabled::description%]", + "name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_enabled::name%]" + }, + "period_3_end": { + "description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_end::description%]", + "name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_end::name%]" + }, + "period_3_start": { + "description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_start::description%]", + "name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_start::name%]" + } + }, + "name": "Write AC discharge times" } }, "title": "Growatt Server" diff --git a/homeassistant/components/growatt_server/switch.py b/homeassistant/components/growatt_server/switch.py index 59cc2535da38e7..8e44e5011ca951 100644 --- a/homeassistant/components/growatt_server/switch.py +++ b/homeassistant/components/growatt_server/switch.py @@ -18,7 +18,6 @@ from .const import DOMAIN from .coordinator import GrowattConfigEntry, GrowattCoordinator -from .sensor.sensor_entity_description import GrowattRequiredKeysMixin _LOGGER = logging.getLogger(__name__) @@ -28,9 +27,10 @@ @dataclass(frozen=True, kw_only=True) -class GrowattSwitchEntityDescription(SwitchEntityDescription, GrowattRequiredKeysMixin): +class GrowattSwitchEntityDescription(SwitchEntityDescription): """Describes Growatt switch entity.""" + api_key: str write_key: str | None = None # Parameter ID for writing (if different from api_key) @@ -87,6 +87,7 @@ def __init__( identifiers={(DOMAIN, coordinator.device_id)}, manufacturer="Growatt", name=coordinator.device_id, + serial_number=coordinator.device_id, ) @property @@ -124,7 +125,11 @@ async def _async_set_state(self, state: bool) -> None: api_value, ) except GrowattV1ApiError as e: - raise HomeAssistantError(f"Error while setting switch state: {e}") from e + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={"error": str(e)}, + ) from e # If no exception was raised, the write was successful _LOGGER.debug( diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index f7509cac4f1139..6f4c99953053b8 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -89,18 +89,18 @@ "step": { "advanced": { "data": { - "api_key": "API Token", + "api_key": "API token", "api_user": "User ID", "url": "[%key:common::config_flow::data::url%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "api_key": "API Token of the Habitica account", + "api_key": "API token of the Habitica account", "api_user": "User ID of your Habitica account", "url": "URL of the Habitica installation to connect to. Defaults to `{default_url}`", "verify_ssl": "Enable SSL certificate verification for secure connections. Disable only if connecting to a Habitica instance using a self-signed certificate" }, - "description": "You can retrieve your `User ID` and `API Token` from [**Settings -> Site Data**]({site_data}) on Habitica or the instance you want to connect to", + "description": "You can retrieve your 'User ID' and 'API token' from [**Settings -> Site Data**]({site_data}) on Habitica or the instance you want to connect to", "title": "[%key:component::habitica::config::step::user::menu_options::advanced%]" }, "login": { @@ -126,7 +126,7 @@ "api_key": "[%key:component::habitica::config::step::advanced::data_description::api_key%]" }, "description": "Enter your new API token below. You can find it in Habitica under 'Settings -> Site Data'", - "name": "Re-authorize via API Token" + "name": "Re-authorize via API token" }, "reauth_login": { "data": { diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 9f164a3d8f1ebc..1bb1bc1d34d0fa 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -9,10 +9,21 @@ import os import re import struct -from typing import Any, NamedTuple +from typing import Any, NamedTuple, cast from aiohasupervisor import SupervisorError -from aiohasupervisor.models import GreenOptions, YellowOptions # noqa: F401 +from aiohasupervisor.models import ( + GreenOptions, + HomeAssistantInfo, + HostInfo, + InstalledAddon, + NetworkInfo, + OSInfo, + RootInfo, + StoreInfo, + SupervisorInfo, + YellowOptions, +) import voluptuous as vol from homeassistant.auth.const import GROUP_ID_ADMIN @@ -21,6 +32,7 @@ from homeassistant.components.http import 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, @@ -34,11 +46,13 @@ async_get_hass_or_none, callback, ) +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError 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 @@ -62,7 +76,7 @@ system_health, update, ) -from .addon_manager import AddonError, AddonInfo, AddonManager, AddonState # noqa: F401 +from .addon_manager import AddonError, AddonInfo, AddonManager, AddonState from .addon_panel import async_setup_addon_panel from .auth import async_setup_auth_view from .config import HassioConfig @@ -79,7 +93,9 @@ ATTR_INPUT, ATTR_LOCATION, ATTR_PASSWORD, + ATTR_REPOSITORIES, ATTR_SLUG, + DATA_ADDONS_LIST, DATA_COMPONENT, DATA_CONFIG_STORE, DATA_CORE_INFO, @@ -92,22 +108,25 @@ DATA_SUPERVISOR_INFO, DOMAIN, HASSIO_UPDATE_INTERVAL, + SupervisorEntityModel, ) from .coordinator import ( HassioDataUpdateCoordinator, get_addons_info, - get_addons_stats, # noqa: F401 - get_core_info, # noqa: F401 - get_core_stats, # noqa: F401 - get_host_info, # noqa: F401 + get_addons_list, + get_addons_stats, + get_core_info, + get_core_stats, + get_host_info, get_info, - get_issues_info, # noqa: F401 + get_network_info, get_os_info, - get_supervisor_info, # noqa: F401 - get_supervisor_stats, # noqa: F401 + get_store, + get_supervisor_info, + get_supervisor_stats, ) from .discovery import async_setup_discovery_view -from .handler import ( # noqa: F401 +from .handler import ( HassIO, HassioAPIError, async_update_diagnostics, @@ -118,6 +137,34 @@ from .issues import SupervisorIssues from .websocket_api import async_load_websocket_api +# Expose the future safe name now so integrations can use it +# All references to addons will eventually be refactored and deprecated +get_apps_list = get_addons_list +__all__ = [ + "AddonError", + "AddonInfo", + "AddonManager", + "AddonState", + "GreenOptions", + "SupervisorError", + "YellowOptions", + "async_update_diagnostics", + "get_addons_info", + "get_addons_list", + "get_addons_stats", + "get_apps_list", + "get_core_info", + "get_core_stats", + "get_host_info", + "get_info", + "get_network_info", + "get_os_info", + "get_store", + "get_supervisor_client", + "get_supervisor_info", + "get_supervisor_stats", +] + _LOGGER = logging.getLogger(__name__) @@ -147,6 +194,7 @@ 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]+$")) @@ -229,6 +277,19 @@ def valid_addon(value: Any) -> str: } ) +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") @@ -444,33 +505,97 @@ async def async_service_handler(service: ServiceCall) -> None: 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 + ) + async def update_info_data(_: datetime | None = None) -> None: """Update last available supervisor information.""" supervisor_client = get_supervisor_client(hass) try: ( - hass.data[DATA_INFO], - hass.data[DATA_HOST_INFO], + root_info, + host_info, store_info, - hass.data[DATA_CORE_INFO], - hass.data[DATA_SUPERVISOR_INFO], - hass.data[DATA_OS_INFO], - hass.data[DATA_NETWORK_INFO], - ) = await asyncio.gather( - create_eager_task(hassio.get_info()), - create_eager_task(hassio.get_host_info()), - create_eager_task(supervisor_client.store.info()), - create_eager_task(hassio.get_core_info()), - create_eager_task(hassio.get_supervisor_info()), - create_eager_task(hassio.get_os_info()), - create_eager_task(hassio.get_network_info()), + homeassistant_info, + supervisor_info, + os_info, + network_info, + addons_list, + ) = cast( + tuple[ + RootInfo, + HostInfo, + StoreInfo, + HomeAssistantInfo, + SupervisorInfo, + OSInfo, + NetworkInfo, + list[InstalledAddon], + ], + await asyncio.gather( + create_eager_task(supervisor_client.info()), + create_eager_task(supervisor_client.host.info()), + create_eager_task(supervisor_client.store.info()), + create_eager_task(supervisor_client.homeassistant.info()), + create_eager_task(supervisor_client.supervisor.info()), + create_eager_task(supervisor_client.os.info()), + create_eager_task(supervisor_client.network.info()), + create_eager_task(supervisor_client.addons.list()), + ), ) - except HassioAPIError as err: + 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, diff --git a/homeassistant/components/hassio/addon_manager.py b/homeassistant/components/hassio/addon_manager.py index 4abe9761065305..f176967923f481 100644 --- a/homeassistant/components/hassio/addon_manager.py +++ b/homeassistant/components/hassio/addon_manager.py @@ -10,7 +10,11 @@ import logging from typing import Any, Concatenate -from aiohasupervisor import SupervisorError +from aiohasupervisor import ( + AddonNotSupportedError, + SupervisorError, + SupervisorNotFoundError, +) from aiohasupervisor.models import ( AddonsOptions, AddonState as SupervisorAddonState, @@ -165,15 +169,7 @@ async def async_get_addon_info(self) -> AddonInfo: ) addon_info = await self._supervisor_client.addons.addon_info(self.addon_slug) - addon_state = self.async_get_addon_state(addon_info) - return AddonInfo( - available=addon_info.available, - hostname=addon_info.hostname, - options=addon_info.options, - state=addon_state, - update_available=addon_info.update_available, - version=addon_info.version, - ) + return self._async_convert_installed_addon_info(addon_info) @callback def async_get_addon_state(self, addon_info: InstalledAddonComplete) -> AddonState: @@ -189,6 +185,20 @@ def async_get_addon_state(self, addon_info: InstalledAddonComplete) -> AddonStat return addon_state + @callback + def _async_convert_installed_addon_info( + self, addon_info: InstalledAddonComplete + ) -> AddonInfo: + """Convert InstalledAddonComplete model to AddonInfo model.""" + return AddonInfo( + available=addon_info.available, + hostname=addon_info.hostname, + options=addon_info.options, + state=self.async_get_addon_state(addon_info), + update_available=addon_info.update_available, + version=addon_info.version, + ) + @api_error( "Failed to set the {addon_name} app options", expected_error_type=SupervisorError, @@ -199,21 +209,17 @@ async def async_set_addon_options(self, config: dict) -> None: self.addon_slug, AddonsOptions(config=config) ) - def _check_addon_available(self, addon_info: AddonInfo) -> None: - """Check if the managed add-on is available.""" - if not addon_info.available: - raise AddonError(f"{self.addon_name} app is not available") - @api_error( "Failed to install the {addon_name} app", expected_error_type=SupervisorError ) async def async_install_addon(self) -> None: """Install the managed add-on.""" - addon_info = await self.async_get_addon_info() - - self._check_addon_available(addon_info) - - await self._supervisor_client.store.install_addon(self.addon_slug) + try: + await self._supervisor_client.store.install_addon(self.addon_slug) + except AddonNotSupportedError as err: + raise AddonError( + f"{self.addon_name} app is not available: {err!s}" + ) from None @api_error( "Failed to uninstall the {addon_name} app", @@ -226,17 +232,29 @@ async def async_uninstall_addon(self) -> None: @api_error("Failed to update the {addon_name} app") async def async_update_addon(self) -> None: """Update the managed add-on if needed.""" - addon_info = await self.async_get_addon_info() - - self._check_addon_available(addon_info) - - if addon_info.state is AddonState.NOT_INSTALLED: - raise AddonError(f"{self.addon_name} app is not installed") + try: + # Not using async_get_addon_info here because it would make an unnecessary + # call to /store/addon/{slug}/info. This will raise if the addon is not + # installed so one call to /addon/{slug}/info is all that is needed + addon_info = await self._supervisor_client.addons.addon_info( + self.addon_slug + ) + except SupervisorNotFoundError: + raise AddonError(f"{self.addon_name} app is not installed") from None if not addon_info.update_available: return - await self.async_create_backup() + try: + await self._supervisor_client.store.addon_availability(self.addon_slug) + except AddonNotSupportedError as err: + raise AddonError( + f"{self.addon_name} app is not available: {err!s}" + ) from None + + await self.async_create_backup( + addon_info=self._async_convert_installed_addon_info(addon_info) + ) await self._supervisor_client.store.update_addon( self.addon_slug, StoreAddonUpdate(backup=False) ) @@ -266,10 +284,14 @@ async def async_stop_addon(self) -> None: "Failed to create a backup of the {addon_name} app", expected_error_type=SupervisorError, ) - async def async_create_backup(self) -> None: + async def async_create_backup(self, *, addon_info: AddonInfo | None = None) -> None: """Create a partial backup of the managed add-on.""" - addon_info = await self.async_get_addon_info() - name = f"addon_{self.addon_slug}_{addon_info.version}" + if addon_info: + addon_version = addon_info.version + else: + addon_version = (await self.async_get_addon_info()).version + + name = f"addon_{self.addon_slug}_{addon_version}" self._logger.debug("Creating backup: %s", name) await self._supervisor_client.backups.partial_backup( diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 1e9a14be1f2960..b7702d9f3b94ad 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -44,6 +44,7 @@ IncorrectPasswordError, ManagerBackup, NewBackup, + OnProgressCallback, RestoreBackupEvent, RestoreBackupStage, RestoreBackupState, @@ -183,6 +184,7 @@ async def async_upload_backup( *, open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], backup: AgentBackup, + on_progress: OnProgressCallback, **kwargs: Any, ) -> None: """Upload a backup. @@ -202,8 +204,17 @@ async def async_upload_backup( location={self.location}, filename=PurePath(suggested_backup_filename(backup)), ) + + async def stream_with_progress() -> AsyncIterator[bytes]: + """Wrap stream to track upload progress.""" + bytes_uploaded = 0 + async for chunk in stream: + bytes_uploaded += len(chunk) + on_progress(bytes_uploaded=bytes_uploaded) + yield chunk + await self._client.backups.upload_backup( - stream, + stream_with_progress(), upload_options, ) diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index d71efb3d09e812..66ffeb9b3c77a0 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -93,6 +93,7 @@ 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) ATTR_AUTO_UPDATE = "auto_update" @@ -106,6 +107,7 @@ ATTR_STARTED = "started" ATTR_URL = "url" ATTR_REPOSITORY = "repository" +ATTR_REPOSITORIES = "repositories" DATA_KEY_ADDONS = "addons" DATA_KEY_OS = "os" @@ -130,6 +132,7 @@ ISSUE_KEY_ADDON_PWNED = "issue_addon_pwned" ISSUE_KEY_SYSTEM_FREE_SPACE = "issue_system_free_space" ISSUE_KEY_ADDON_DEPRECATED = "issue_addon_deprecated_addon" +ISSUE_KEY_ADDON_DEPRECATED_ARCH = "issue_addon_deprecated_arch_addon" ISSUE_MOUNT_MOUNT_FAILED = "issue_mount_mount_failed" @@ -170,6 +173,7 @@ "more_info_pwned": "https://www.home-assistant.io/more-info/pwned-passwords", }, ISSUE_KEY_ADDON_DEPRECATED: HELP_URLS, + ISSUE_KEY_ADDON_DEPRECATED_ARCH: HELP_URLS, } diff --git a/homeassistant/components/hassio/coordinator.py b/homeassistant/components/hassio/coordinator.py index e67b76458a6787..679614acbecaa3 100644 --- a/homeassistant/components/hassio/coordinator.py +++ b/homeassistant/components/hassio/coordinator.py @@ -4,13 +4,20 @@ import asyncio from collections import defaultdict +from collections.abc import Awaitable from copy import deepcopy import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from aiohasupervisor import SupervisorError, SupervisorNotFoundError -from aiohasupervisor.models import StoreInfo -from aiohasupervisor.models.mounts import CIFSMountResponse, NFSMountResponse +from aiohasupervisor.models import ( + AddonState, + CIFSMountResponse, + InstalledAddon, + NFSMountResponse, + StoreInfo, +) +from aiohasupervisor.models.base import ResponseData from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_MANUFACTURER, ATTR_NAME @@ -23,16 +30,16 @@ from .const import ( ATTR_AUTO_UPDATE, + ATTR_REPOSITORIES, ATTR_REPOSITORY, ATTR_SLUG, - ATTR_STARTED, - ATTR_STATE, ATTR_URL, ATTR_VERSION, CONTAINER_INFO, CONTAINER_STATS, CORE_CONTAINER, DATA_ADDONS_INFO, + DATA_ADDONS_LIST, DATA_ADDONS_STATS, DATA_COMPONENT, DATA_CORE_INFO, @@ -57,7 +64,7 @@ SUPERVISOR_CONTAINER, SupervisorEntityModel, ) -from .handler import HassioAPIError, get_supervisor_client +from .handler import get_supervisor_client from .jobs import SupervisorJobs if TYPE_CHECKING: @@ -118,7 +125,7 @@ def get_network_info(hass: HomeAssistant) -> dict[str, Any] | None: @callback @bind_hass -def get_addons_info(hass: HomeAssistant) -> dict[str, dict[str, Any]] | None: +def get_addons_info(hass: HomeAssistant) -> dict[str, dict[str, Any] | None] | None: """Return Addons info. Async friendly. @@ -126,9 +133,18 @@ def get_addons_info(hass: HomeAssistant) -> dict[str, dict[str, Any]] | None: return hass.data.get(DATA_ADDONS_INFO) +@callback +def get_addons_list(hass: HomeAssistant) -> list[dict[str, Any]] | None: + """Return list of installed addons and subset of details for each. + + Async friendly. + """ + return hass.data.get(DATA_ADDONS_LIST) + + @callback @bind_hass -def get_addons_stats(hass: HomeAssistant) -> dict[str, Any]: +def get_addons_stats(hass: HomeAssistant) -> dict[str, dict[str, Any] | None]: """Return Addons stats. Async friendly. @@ -341,7 +357,7 @@ async def _async_update_data(self) -> dict[str, Any]: try: await self.force_data_refresh(is_first_update) - except HassioAPIError as err: + except SupervisorError as err: raise UpdateFailed(f"Error on Supervisor API: {err}") from err new_data: dict[str, Any] = {} @@ -350,6 +366,7 @@ async def _async_update_data(self) -> dict[str, Any]: 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 = { @@ -360,17 +377,17 @@ async def _async_update_data(self) -> dict[str, Any]: repositories = {} new_data[DATA_KEY_ADDONS] = { - addon[ATTR_SLUG]: { + (slug := addon[ATTR_SLUG]): { **addon, - **((addons_stats or {}).get(addon[ATTR_SLUG]) or {}), - ATTR_AUTO_UPDATE: (addons_info.get(addon[ATTR_SLUG]) or {}).get( + **(addons_stats.get(slug) or {}), + ATTR_AUTO_UPDATE: (addons_info.get(slug) or {}).get( ATTR_AUTO_UPDATE, False ), ATTR_REPOSITORY: repositories.get( - addon.get(ATTR_REPOSITORY), addon.get(ATTR_REPOSITORY, "") + repo_slug := addon.get(ATTR_REPOSITORY, ""), repo_slug ), } - for addon in supervisor_info.get("addons", []) + for addon in addons_list } if self.is_hass_os: new_data[DATA_KEY_OS] = get_os_info(self.hass) @@ -462,32 +479,48 @@ async def force_data_refresh(self, first_update: bool) -> None: container_updates = self._container_updates data = self.hass.data - hassio = self.hassio - updates = { - DATA_INFO: hassio.get_info(), - DATA_CORE_INFO: hassio.get_core_info(), - DATA_SUPERVISOR_INFO: hassio.get_supervisor_info(), - DATA_OS_INFO: hassio.get_os_info(), + 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] = hassio.get_core_stats() + updates[DATA_CORE_STATS] = client.homeassistant.stats() if CONTAINER_STATS in container_updates[SUPERVISOR_CONTAINER]: - updates[DATA_SUPERVISOR_STATS] = hassio.get_supervisor_stats() - - results = await asyncio.gather(*updates.values()) - for key, result in zip(updates, results, strict=False): - data[key] = result - - _addon_data = data[DATA_SUPERVISOR_INFO].get("addons", []) - all_addons: list[str] = [] - started_addons: list[str] = [] - for addon in _addon_data: - slug = addon[ATTR_SLUG] - all_addons.append(slug) - if addon[ATTR_STATE] == ATTR_STARTED: - started_addons.append(slug) + 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 add-on info if its the first update or + # 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 @@ -514,6 +547,12 @@ async def force_data_refresh(self, first_update: bool) -> None: ), ): 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( @@ -540,7 +579,7 @@ async def _update_addon_stats(self, slug: str) -> tuple[str, dict[str, Any] | No return (slug, stats.to_dict()) async def _update_addon_info(self, slug: str) -> tuple[str, dict[str, Any] | None]: - """Return the info for an add-on.""" + """Return the info for an addon.""" try: info = await self.supervisor_client.addons.addon_info(slug) except SupervisorError as err: diff --git a/homeassistant/components/hassio/diagnostics.py b/homeassistant/components/hassio/diagnostics.py index 0ef50cedc5af36..9002310bfcc4e8 100644 --- a/homeassistant/components/hassio/diagnostics.py +++ b/homeassistant/components/hassio/diagnostics.py @@ -6,6 +6,7 @@ from attr import asdict +from homeassistant.components.diagnostics import entity_entry_as_dict from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -44,7 +45,9 @@ async def async_get_config_entry_diagnostics( state_dict = dict(state.as_dict()) state_dict.pop("context", None) - entities.append({"entry": asdict(entity_entry), "state": state_dict}) + entities.append( + {"entry": entity_entry_as_dict(entity_entry), "state": state_dict} + ) devices.append({"device": asdict(device), "entities": entities}) diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 2fb52034fcec2b..f4055efd8df32f 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -87,70 +87,6 @@ def base_url(self) -> URL: """Return base url for Supervisor.""" return self._base_url - @api_data - def get_info(self) -> Coroutine: - """Return generic Supervisor information. - - This method returns a coroutine. - """ - return self.send_command("/info", method="get") - - @api_data - def get_host_info(self) -> Coroutine: - """Return data for Host. - - This method returns a coroutine. - """ - return self.send_command("/host/info", method="get") - - @api_data - def get_os_info(self) -> Coroutine: - """Return data for the OS. - - This method returns a coroutine. - """ - return self.send_command("/os/info", method="get") - - @api_data - def get_core_info(self) -> Coroutine: - """Return data for Home Asssistant Core. - - This method returns a coroutine. - """ - return self.send_command("/core/info", method="get") - - @api_data - def get_supervisor_info(self) -> Coroutine: - """Return data for the Supervisor. - - This method returns a coroutine. - """ - return self.send_command("/supervisor/info", method="get") - - @api_data - def get_network_info(self) -> Coroutine: - """Return data for the Host Network. - - This method returns a coroutine. - """ - return self.send_command("/network/info", method="get") - - @api_data - def get_core_stats(self) -> Coroutine: - """Return stats for the core. - - This method returns a coroutine. - """ - return self.send_command("/core/stats", method="get") - - @api_data - def get_supervisor_stats(self) -> Coroutine: - """Return stats for the supervisor. - - This method returns a coroutine. - """ - return self.send_command("/supervisor/stats", method="get") - @api_data def get_ingress_panels(self) -> Coroutine: """Return data for Add-on ingress panels. diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 60417a3dd6521e..d0304e3f34d071 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -266,6 +266,8 @@ def should_compress(content_type: str, path: str | None = None) -> bool: """Return if we should compress a response.""" if path is not None and NO_COMPRESS.match(path): return False + if content_type.startswith("text/event-stream"): + return False if content_type.startswith("image/"): return "svg" in content_type if content_type.startswith("application/"): diff --git a/homeassistant/components/hassio/icons.json b/homeassistant/components/hassio/icons.json index 49111914c81dc9..0037409c6d3a9a 100644 --- a/homeassistant/components/hassio/icons.json +++ b/homeassistant/components/hassio/icons.json @@ -46,6 +46,9 @@ "host_shutdown": { "service": "mdi:power" }, + "mount_reload": { + "service": "mdi:reload" + }, "restore_full": { "service": "mdi:backup-restore" }, diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 284138956ff967..1df19226d5e842 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -45,6 +45,7 @@ } MIN_COMPRESSED_SIZE = 128 +MAX_WEBSOCKET_MESSAGE_SIZE = 16 * 1024 * 1024 # 16 MiB MAX_SIMPLE_RESPONSE_SIZE = 4194000 DISABLED_TIMEOUT = ClientTimeout(total=None) @@ -126,7 +127,10 @@ async def _handle_websocket( req_protocols = () ws_server = web.WebSocketResponse( - protocols=req_protocols, autoclose=False, autoping=False + protocols=req_protocols, + autoclose=False, + autoping=False, + max_msg_size=MAX_WEBSOCKET_MESSAGE_SIZE, ) await ws_server.prepare(request) @@ -149,6 +153,7 @@ async def _handle_websocket( protocols=req_protocols, autoclose=False, autoping=False, + max_msg_size=MAX_WEBSOCKET_MESSAGE_SIZE, ) as ws_client: # Proxy requests await asyncio.wait( @@ -181,8 +186,7 @@ async def _handle_request( skip_auto_headers={hdrs.CONTENT_TYPE}, ) as result: headers = _response_header(result) - content_length_int = 0 - content_length = result.headers.get(hdrs.CONTENT_LENGTH, UNDEFINED) + # Avoid parsing content_type in simple cases for better performance if maybe_content_type := result.headers.get(hdrs.CONTENT_TYPE): content_type: str = (maybe_content_type.partition(";"))[0].strip() @@ -190,17 +194,30 @@ async def _handle_request( # default value according to RFC 2616 content_type = "application/octet-stream" + # Empty body responses (304, 204, HEAD, etc.) should not be streamed, + # otherwise aiohttp < 3.9.0 may generate an invalid "0\r\n\r\n" chunk + # This also avoids setting content_type for empty responses. + if must_be_empty_body(request.method, result.status): + # If upstream contains content-type, preserve it (e.g. for HEAD requests) + # Note: This still is omitting content-length. We can't simply forward + # the upstream length since the proxy might change the body length + # (e.g. due to compression). + if maybe_content_type: + headers[hdrs.CONTENT_TYPE] = content_type + return web.Response( + headers=headers, + status=result.status, + ) + # Simple request - if (empty_body := must_be_empty_body(result.method, result.status)) or ( + content_length_int = 0 + content_length = result.headers.get(hdrs.CONTENT_LENGTH, UNDEFINED) + if ( content_length is not UNDEFINED and (content_length_int := int(content_length)) <= MAX_SIMPLE_RESPONSE_SIZE ): - # Return Response - if empty_body: - body = None - else: - body = await result.read() + body = await result.read() simple_response = web.Response( headers=headers, status=result.status, diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index 25b4db9c861361..694e6c3c4fb472 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -17,6 +17,7 @@ UnsupportedReason, ) +from homeassistant.const import ATTR_NAME from homeassistant.core import HassJob, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_call_later @@ -30,6 +31,7 @@ ADDONS_COORDINATOR, ATTR_DATA, ATTR_HEALTHY, + ATTR_SLUG, ATTR_STARTUP, ATTR_SUPPORTED, ATTR_UNHEALTHY_REASONS, @@ -45,6 +47,7 @@ EVENT_SUPPORTED_CHANGED, EXTRA_PLACEHOLDERS, ISSUE_KEY_ADDON_BOOT_FAIL, + ISSUE_KEY_ADDON_DEPRECATED_ARCH, ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING, ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, ISSUE_KEY_ADDON_PWNED, @@ -59,7 +62,7 @@ STARTUP_COMPLETE, UPDATE_KEY_SUPERVISOR, ) -from .coordinator import HassioDataUpdateCoordinator, get_addons_info, get_host_info +from .coordinator import HassioDataUpdateCoordinator, get_addons_list, get_host_info from .handler import HassIO, get_supervisor_client ISSUE_KEY_UNHEALTHY = "unhealthy" @@ -88,6 +91,8 @@ "issue_system_disk_lifetime", ISSUE_KEY_SYSTEM_FREE_SPACE, ISSUE_KEY_ADDON_PWNED, + ISSUE_KEY_ADDON_DEPRECATED_ARCH, + "issue_system_ntp_sync_failed", } _LOGGER = logging.getLogger(__name__) @@ -251,9 +256,10 @@ def issues(self) -> set[Issue]: def add_issue(self, issue: Issue) -> None: """Add or update an issue in the list. Create or update a repair if necessary.""" if issue.key in ISSUE_KEYS_FOR_REPAIRS: - placeholders: dict[str, str] = {} if not issue.suggestions and issue.key in EXTRA_PLACEHOLDERS: - placeholders |= EXTRA_PLACEHOLDERS[issue.key] + placeholders: dict[str, str] = EXTRA_PLACEHOLDERS[issue.key].copy() + else: + placeholders = {} if issue.reference: placeholders[PLACEHOLDER_KEY_REFERENCE] = issue.reference @@ -265,23 +271,18 @@ def add_issue(self, issue: Issue) -> None: placeholders[PLACEHOLDER_KEY_ADDON_URL] = ( f"/hassio/addon/{issue.reference}" ) - addons = get_addons_info(self._hass) - if addons and issue.reference in addons: - placeholders[PLACEHOLDER_KEY_ADDON] = addons[issue.reference][ - "name" - ] - else: - placeholders[PLACEHOLDER_KEY_ADDON] = issue.reference + addons_list = get_addons_list(self._hass) or [] + placeholders[PLACEHOLDER_KEY_ADDON] = issue.reference + for addon in addons_list: + if addon[ATTR_SLUG] == issue.reference: + placeholders[PLACEHOLDER_KEY_ADDON] = addon[ATTR_NAME] + break elif issue.key == ISSUE_KEY_SYSTEM_FREE_SPACE: host_info = get_host_info(self._hass) - if ( - host_info - and "data" in host_info - and "disk_free" in host_info["data"] - ): + if host_info and "disk_free" in host_info: placeholders[PLACEHOLDER_KEY_FREE_SPACE] = str( - host_info["data"]["disk_free"] + host_info["disk_free"] ) else: placeholders[PLACEHOLDER_KEY_FREE_SPACE] = "<2" diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index c6a419bba835ea..61ddf4a82621be 100644 --- a/homeassistant/components/hassio/manifest.json +++ b/homeassistant/components/hassio/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/hassio", "iot_class": "local_polling", "quality_scale": "internal", - "requirements": ["aiohasupervisor==0.3.3"], + "requirements": ["aiohasupervisor==0.4.2"], "single_config_entry": true } diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py index de90026be5b17f..11dbb939749464 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/repairs.py @@ -11,14 +11,17 @@ import voluptuous as vol from homeassistant.components.repairs import RepairsFlow +from homeassistant.const import ATTR_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult -from . import get_addons_info, get_issues_info +from . import get_addons_list from .const import ( + ATTR_SLUG, EXTRA_PLACEHOLDERS, ISSUE_KEY_ADDON_BOOT_FAIL, ISSUE_KEY_ADDON_DEPRECATED, + ISSUE_KEY_ADDON_DEPRECATED_ARCH, ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, ISSUE_KEY_ADDON_PWNED, ISSUE_KEY_SYSTEM_DOCKER_CONFIG, @@ -28,6 +31,7 @@ PLACEHOLDER_KEY_COMPONENTS, PLACEHOLDER_KEY_REFERENCE, ) +from .coordinator import get_issues_info from .handler import get_supervisor_client from .issues import Issue, Suggestion @@ -62,11 +66,16 @@ def issue(self) -> Issue | None: @property def description_placeholders(self) -> dict[str, str] | None: """Get description placeholders for steps.""" - placeholders = {} - if self.issue: - placeholders = EXTRA_PLACEHOLDERS.get(self.issue.key, {}) - if self.issue.reference: - placeholders |= {PLACEHOLDER_KEY_REFERENCE: self.issue.reference} + if not self.issue: + return None + + if self.issue.key in EXTRA_PLACEHOLDERS: + placeholders: dict[str, str] = EXTRA_PLACEHOLDERS[self.issue.key].copy() + else: + placeholders = {} + + if self.issue.reference: + placeholders |= {PLACEHOLDER_KEY_REFERENCE: self.issue.reference} return placeholders or None @@ -154,7 +163,7 @@ def description_placeholders(self) -> dict[str, str] | None: placeholders = {PLACEHOLDER_KEY_COMPONENTS: ""} supervisor_issues = get_issues_info(self.hass) if supervisor_issues and self.issue: - addons = get_addons_info(self.hass) or {} + addons_list = get_addons_list(self.hass) or [] components: list[str] = [] for issue in supervisor_issues.issues: if issue.key == self.issue.key or issue.type != self.issue.type: @@ -166,9 +175,9 @@ def description_placeholders(self) -> dict[str, str] | None: components.append( next( ( - info["name"] - for slug, info in addons.items() - if slug == issue.reference + addon[ATTR_NAME] + for addon in addons_list + if addon[ATTR_SLUG] == issue.reference ), issue.reference or "", ) @@ -187,13 +196,12 @@ def description_placeholders(self) -> dict[str, str] | None: """Get description placeholders for steps.""" placeholders: dict[str, str] = super().description_placeholders or {} if self.issue and self.issue.reference: - addons = get_addons_info(self.hass) - if addons and self.issue.reference in addons: - placeholders[PLACEHOLDER_KEY_ADDON] = addons[self.issue.reference][ - "name" - ] - else: - placeholders[PLACEHOLDER_KEY_ADDON] = self.issue.reference + addons_list = get_addons_list(self.hass) or [] + placeholders[PLACEHOLDER_KEY_ADDON] = self.issue.reference + for addon in addons_list: + if addon[ATTR_SLUG] == self.issue.reference: + placeholders[PLACEHOLDER_KEY_ADDON] = addon[ATTR_NAME] + break return placeholders or None @@ -231,6 +239,7 @@ async def async_create_fix_flow( ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, ISSUE_KEY_ADDON_BOOT_FAIL, ISSUE_KEY_ADDON_PWNED, + ISSUE_KEY_ADDON_DEPRECATED_ARCH, }: return AddonIssueRepairFlow(hass, issue_id) diff --git a/homeassistant/components/hassio/services.yaml b/homeassistant/components/hassio/services.yaml index 0d00255264e75a..6aa279f9a42a73 100644 --- a/homeassistant/components/hassio/services.yaml +++ b/homeassistant/components/hassio/services.yaml @@ -165,3 +165,13 @@ restore_partial: example: "password" selector: text: + +mount_reload: + fields: + device_id: + required: true + selector: + device: + filter: + integration: hassio + model: Home Assistant Mount diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index e480fb794f4c74..d570c07193f022 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -43,6 +43,17 @@ } } }, + "exceptions": { + "mount_reload_error": { + "message": "Failed to reload mount {name}: {error}" + }, + "mount_reload_invalid_device": { + "message": "Device is not a supervisor mount point" + }, + "mount_reload_unknown_device_id": { + "message": "Device ID not found" + } + }, "issues": { "issue_addon_boot_fail": { "fix_flow": { @@ -74,6 +85,19 @@ }, "title": "Installed app is deprecated" }, + "issue_addon_deprecated_arch_addon": { + "fix_flow": { + "abort": { + "apply_suggestion_fail": "Could not uninstall the app. Check the Supervisor logs for more details." + }, + "step": { + "addon_execute_remove": { + "description": "App {addon} only supports architectures and/or machines which are no longer supported by Home Assistant. It will stop working in a future release.\n\nSelecting **Submit** will uninstall this deprecated app. Alternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to." + } + } + }, + "title": "Installed app is built for unsupported architectures and/or machines" + }, "issue_addon_detached_addon_missing": { "description": "Repository for app {addon} is missing. This means it will not get updates, and backups may not be restored correctly as the Home Assistant Supervisor may not be able to build/download the resources required.\n\nPlease check the [app's documentation]({addon_url}) for installation instructions and add the repository to the store.", "title": "Missing repository for an installed app" @@ -153,6 +177,19 @@ }, "title": "Multiple data disks detected" }, + "issue_system_ntp_sync_failed": { + "fix_flow": { + "abort": { + "apply_suggestion_fail": "Could not re-enable NTP. Check the Supervisor logs for more details." + }, + "step": { + "system_enable_ntp": { + "description": "The device could not contact its configured time servers (NTP). Using a secondary online time check, we detected that the system clock was more than 1 hour incorrect. The time has been corrected and the NTP service was temporarily disabled so the correction could be applied. To keep the system time accurate, we recommend fixing the issue preventing access to the NTP servers.\n\nCheck the **Host logs** to investigate why NTP servers could not be reached. Once resolved, select **Submit** to re-enable the NTP service." + } + } + }, + "title": "Time synchronization issue detected" + }, "issue_system_reboot_required": { "fix_flow": { "abort": { @@ -214,10 +251,6 @@ "description": "System is unsupported because Home Assistant cannot determine when an Internet connection is available. For troubleshooting information, select Learn more.", "title": "Unsupported system - Connectivity check disabled" }, - "unsupported_content_trust": { - "description": "System is unsupported because Home Assistant cannot verify content being run is trusted and not modified by attackers. For troubleshooting information, select Learn more.", - "title": "Unsupported system - Content-trust check disabled" - }, "unsupported_dbus": { "description": "System is unsupported because D-Bus is working incorrectly. Many things fail without this as Supervisor cannot communicate with the host. For troubleshooting information, select Learn more.", "title": "Unsupported system - D-Bus issues" @@ -270,10 +303,6 @@ "description": "System is unsupported because additional software outside the Home Assistant ecosystem has been detected. For troubleshooting information, select Learn more.", "title": "Unsupported system - Unsupported software" }, - "unsupported_source_mods": { - "description": "System is unsupported because Supervisor source code has been modified. For troubleshooting information, select Learn more.", - "title": "Unsupported system - Supervisor source modifications" - }, "unsupported_supervisor_version": { "description": "System is unsupported because an out-of-date version of Supervisor is in use and auto-update has been disabled. For troubleshooting information, select Learn more.", "title": "Unsupported system - Supervisor version" @@ -456,6 +485,16 @@ "description": "Powers off the host system.", "name": "Power off the host system" }, + "mount_reload": { + "description": "Reloads a network storage mount.", + "fields": { + "device_id": { + "description": "The device ID of the network storage mount to reload.", + "name": "Device ID" + } + }, + "name": "Reload network storage mount" + }, "restore_full": { "description": "Restores from full backup.", "fields": { diff --git a/homeassistant/components/hassio/system_health.py b/homeassistant/components/hassio/system_health.py index 0a7e9b51e97a13..ade621df9338c7 100644 --- a/homeassistant/components/hassio/system_health.py +++ b/homeassistant/components/hassio/system_health.py @@ -9,6 +9,7 @@ from homeassistant.core import HomeAssistant, callback from .coordinator import ( + get_addons_list, get_host_info, get_info, get_network_info, @@ -35,6 +36,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: host_info = get_host_info(hass) or {} supervisor_info = get_supervisor_info(hass) network_info = get_network_info(hass) or {} + addons_list = get_addons_list(hass) or [] healthy: bool | dict[str, str] if supervisor_info is not None and supervisor_info.get("healthy"): @@ -84,6 +86,8 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: os_info = get_os_info(hass) or {} information["board"] = os_info.get("board") + # Not using aiohasupervisor for ping call below intentionally. Given system health + # context, it seems preferable to do this check with minimal dependencies information["supervisor_api"] = system_health.async_check_can_reach_url( hass, SUPERVISOR_PING.format(ip_address=ip_address), @@ -95,8 +99,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: ) information["installed_addons"] = ", ".join( - f"{addon['name']} ({addon['version']})" - for addon in (supervisor_info or {}).get("addons", []) + f"{addon['name']} ({addon['version']})" for addon in addons_list ) return information diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py index b9db22d558de99..5354f21e72635e 100644 --- a/homeassistant/components/hassio/update.py +++ b/homeassistant/components/hassio/update.py @@ -152,6 +152,8 @@ async def async_install( **kwargs: Any, ) -> None: """Install an update.""" + self._attr_in_progress = True + self.async_write_ha_state() await update_addon( self.hass, self._addon_slug, backup, self.title, self.installed_version ) @@ -205,7 +207,7 @@ def installed_version(self) -> str: @property def entity_picture(self) -> str | None: """Return the icon of the entity.""" - return "https://brands.home-assistant.io/homeassistant/icon.png" + return "/api/brands/integration/homeassistant/icon.png?placeholder=no" @property def release_url(self) -> str | None: @@ -256,7 +258,7 @@ def release_url(self) -> str | None: @property def entity_picture(self) -> str | None: """Return the icon of the entity.""" - return "https://brands.home-assistant.io/hassio/icon.png" + return "/api/brands/integration/hassio/icon.png?placeholder=no" async def async_install( self, version: str | None, backup: bool, **kwargs: Any @@ -294,7 +296,7 @@ def installed_version(self) -> str: @property def entity_picture(self) -> str | None: """Return the icon of the entity.""" - return "https://brands.home-assistant.io/homeassistant/icon.png" + return "/api/brands/integration/homeassistant/icon.png?placeholder=no" @property def release_url(self) -> str | None: @@ -308,6 +310,8 @@ async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install an update.""" + self._attr_in_progress = True + self.async_write_ha_state() await update_core(self.hass, version, backup) @callback diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index 8f8e9913d3f59b..534106c4957a1a 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -39,7 +39,7 @@ WS_TYPE_EVENT, WS_TYPE_SUBSCRIBE, ) -from .coordinator import get_supervisor_info +from .coordinator import get_addons_list from .update_helper import update_addon, update_core SCHEMA_WEBSOCKET_EVENT = vol.Schema( @@ -168,8 +168,8 @@ async def websocket_update_addon( """Websocket handler to update an addon.""" addon_name: str | None = None addon_version: str | None = None - addons: list = (get_supervisor_info(hass) or {}).get("addons", []) - for addon in addons: + addons_list: list[dict[str, Any]] = get_addons_list(hass) or [] + for addon in addons_list: if addon[ATTR_SLUG] == msg["addon"]: addon_name = addon[ATTR_NAME] addon_version = addon[ATTR_VERSION] diff --git a/homeassistant/components/haveibeenpwned/sensor.py b/homeassistant/components/haveibeenpwned/sensor.py index d9d2889848e8f1..0e8de64d7c61b4 100644 --- a/homeassistant/components/haveibeenpwned/sensor.py +++ b/homeassistant/components/haveibeenpwned/sensor.py @@ -5,6 +5,7 @@ from datetime import timedelta from http import HTTPStatus import logging +from typing import TYPE_CHECKING, Any import requests import voluptuous as vol @@ -59,40 +60,26 @@ class HaveIBeenPwnedSensor(SensorEntity): _attr_attribution = "Data provided by Have I Been Pwned (HIBP)" - def __init__(self, data, email): + def __init__(self, data: HaveIBeenPwnedData, email: str) -> None: """Initialize the HaveIBeenPwned sensor.""" - self._state = None self._data = data self._email = email - self._unit_of_measurement = "Breaches" + self._attr_name = f"Breaches {email}" + self._attr_native_unit_of_measurement = "Breaches" @property - def name(self): - """Return the name of the sensor.""" - return f"Breaches {self._email}" - - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit_of_measurement - - @property - def native_value(self): - """Return the state of the device.""" - return self._state - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the attributes of the sensor.""" - val = {} + val: dict[str, Any] = {} if self._email not in self._data.data: return val for idx, value in enumerate(self._data.data[self._email]): tmpname = f"breach {idx + 1}" - datetime_local = dt_util.as_local( - dt_util.parse_datetime(value["AddedDate"]) - ) + parsed_datetime = dt_util.parse_datetime(value["AddedDate"]) + if TYPE_CHECKING: + assert parsed_datetime is not None + datetime_local = dt_util.as_local(parsed_datetime) tmpvalue = f"{value['Title']} {datetime_local.strftime(DATE_STR_FORMAT)}" val[tmpname] = tmpvalue @@ -121,7 +108,7 @@ def update_nothrottle(self, dummy=None): ) return - self._state = len(self._data.data[self._email]) + self._attr_native_value = len(self._data.data[self._email]) self.schedule_update_ha_state() def update(self) -> None: @@ -129,7 +116,7 @@ def update(self) -> None: self._data.update() if self._email in self._data.data: - self._state = len(self._data.data[self._email]) + self._attr_native_value = len(self._data.data[self._email]) class HaveIBeenPwnedData: diff --git a/homeassistant/components/hdfury/__init__.py b/homeassistant/components/hdfury/__init__.py index fcf40cbbac0cad..9e8f1cc092c539 100644 --- a/homeassistant/components/hdfury/__init__.py +++ b/homeassistant/components/hdfury/__init__.py @@ -7,6 +7,7 @@ PLATFORMS = [ Platform.BUTTON, + Platform.NUMBER, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/hdfury/icons.json b/homeassistant/components/hdfury/icons.json index 91d1c3c6784b5d..67c854a761dc7e 100644 --- a/homeassistant/components/hdfury/icons.json +++ b/homeassistant/components/hdfury/icons.json @@ -5,6 +5,20 @@ "default": "mdi:connection" } }, + "number": { + "audio_unmute": { + "default": "mdi:volume-high" + }, + "earc_unmute": { + "default": "mdi:volume-high" + }, + "oled_fade": { + "default": "mdi:cellphone-information" + }, + "reboot_timer": { + "default": "mdi:timer-refresh" + } + }, "select": { "opmode": { "default": "mdi:cogs" diff --git a/homeassistant/components/hdfury/manifest.json b/homeassistant/components/hdfury/manifest.json index 223db62a793ddb..093a475fbc05ca 100644 --- a/homeassistant/components/hdfury/manifest.json +++ b/homeassistant/components/hdfury/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["hdfury==1.5.0"], + "requirements": ["hdfury==1.6.0"], "zeroconf": [ { "name": "diva-*", "type": "_http._tcp.local." }, { "name": "vertex2-*", "type": "_http._tcp.local." }, diff --git a/homeassistant/components/hdfury/number.py b/homeassistant/components/hdfury/number.py new file mode 100644 index 00000000000000..3f36fbab18a036 --- /dev/null +++ b/homeassistant/components/hdfury/number.py @@ -0,0 +1,127 @@ +"""Number platform for HDFury Integration.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from hdfury import HDFuryAPI, HDFuryError + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.const import EntityCategory, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import HDFuryConfigEntry +from .entity import HDFuryEntity + +PARALLEL_UPDATES = 1 + + +@dataclass(kw_only=True, frozen=True) +class HDFuryNumberEntityDescription(NumberEntityDescription): + """Description for HDFury number entities.""" + + set_value_fn: Callable[[HDFuryAPI, str], Awaitable[None]] + + +NUMBERS: tuple[HDFuryNumberEntityDescription, ...] = ( + HDFuryNumberEntityDescription( + key="unmutecnt", + translation_key="audio_unmute", + entity_registry_enabled_default=False, + mode=NumberMode.BOX, + native_min_value=50, + native_max_value=1000, + native_step=1, + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MILLISECONDS, + entity_category=EntityCategory.CONFIG, + set_value_fn=lambda client, value: client.set_audio_unmute(value), + ), + HDFuryNumberEntityDescription( + key="earcunmutecnt", + translation_key="earc_unmute", + entity_registry_enabled_default=False, + mode=NumberMode.BOX, + native_min_value=0, + native_max_value=1000, + native_step=1, + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MILLISECONDS, + entity_category=EntityCategory.CONFIG, + set_value_fn=lambda client, value: client.set_earc_unmute(value), + ), + HDFuryNumberEntityDescription( + key="oledfade", + translation_key="oled_fade", + mode=NumberMode.BOX, + native_min_value=1, + native_max_value=100, + native_step=1, + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + entity_category=EntityCategory.CONFIG, + set_value_fn=lambda client, value: client.set_oled_fade(value), + ), + HDFuryNumberEntityDescription( + key="reboottimer", + translation_key="reboot_timer", + mode=NumberMode.BOX, + native_min_value=0, + native_max_value=100, + native_step=1, + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + entity_category=EntityCategory.CONFIG, + set_value_fn=lambda client, value: client.set_reboot_timer(value), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HDFuryConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up numbers using the platform schema.""" + + coordinator = entry.runtime_data + + async_add_entities( + HDFuryNumber(coordinator, description) + for description in NUMBERS + if description.key in coordinator.data.config + ) + + +class HDFuryNumber(HDFuryEntity, NumberEntity): + """Base HDFury Number Class.""" + + entity_description: HDFuryNumberEntityDescription + + @property + def native_value(self) -> float: + """Return the current number value.""" + + return float(self.coordinator.data.config[self.entity_description.key]) + + async def async_set_native_value(self, value: float) -> None: + """Set Number Value Event.""" + + try: + await self.entity_description.set_value_fn( + self.coordinator.client, str(int(value)) + ) + except HDFuryError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="communication_error", + ) from error + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/hdfury/strings.json b/homeassistant/components/hdfury/strings.json index 982c0d69ed5c4f..e7ade56c937138 100644 --- a/homeassistant/components/hdfury/strings.json +++ b/homeassistant/components/hdfury/strings.json @@ -40,6 +40,20 @@ "name": "Issue hotplug" } }, + "number": { + "audio_unmute": { + "name": "Unmute delay" + }, + "earc_unmute": { + "name": "eARC unmute delay" + }, + "oled_fade": { + "name": "OLED fade timer" + }, + "reboot_timer": { + "name": "Restart timer" + } + }, "select": { "opmode": { "name": "Operation mode", @@ -164,10 +178,10 @@ "name": "Relay" }, "tx0plus5": { - "name": "TX0 force +5v" + "name": "TX0 force +5V" }, "tx1plus5": { - "name": "TX1 force +5v" + "name": "TX1 force +5V" } } }, diff --git a/homeassistant/components/hdmi_cec/__init__.py b/homeassistant/components/hdmi_cec/__init__.py index 3e31dd73b5d435..3f948a4474f789 100644 --- a/homeassistant/components/hdmi_cec/__init__.py +++ b/homeassistant/components/hdmi_cec/__init__.py @@ -23,8 +23,8 @@ from pycec.tcp import TcpAdapter import voluptuous as vol -from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER -from homeassistant.components.switch import DOMAIN as SWITCH +from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( CONF_DEVICES, CONF_HOST, @@ -122,11 +122,13 @@ vol.Optional(CONF_DEVICES): vol.Any( DEVICE_SCHEMA, vol.Schema({vol.All(cv.string): vol.Any(cv.string)}) ), - vol.Optional(CONF_PLATFORM): vol.Any(SWITCH, MEDIA_PLAYER), + vol.Optional(CONF_PLATFORM): vol.Any( + SWITCH_DOMAIN, MEDIA_PLAYER_DOMAIN + ), vol.Optional(CONF_HOST): cv.string, vol.Optional(CONF_DISPLAY_NAME): cv.string, vol.Optional(CONF_TYPES, default={}): vol.Schema( - {cv.entity_id: vol.Any(MEDIA_PLAYER, SWITCH)} + {cv.entity_id: vol.Any(MEDIA_PLAYER_DOMAIN, SWITCH_DOMAIN)} ), } ) @@ -170,7 +172,7 @@ def setup(hass: HomeAssistant, base_config: ConfigType) -> bool: # noqa: C901 device_aliases.update(parse_mapping(devices)) _LOGGER.debug("Parsed devices: %s", device_aliases) - platform = base_config[DOMAIN].get(CONF_PLATFORM, SWITCH) + platform = base_config[DOMAIN].get(CONF_PLATFORM, SWITCH_DOMAIN) loop = ( # Create own thread if more than 1 CPU diff --git a/homeassistant/components/hdmi_cec/entity.py b/homeassistant/components/hdmi_cec/entity.py index 60ea4e1a0d0774..cc10fd95531bef 100644 --- a/homeassistant/components/hdmi_cec/entity.py +++ b/homeassistant/components/hdmi_cec/entity.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + from homeassistant.helpers.entity import Entity from .const import DOMAIN, EVENT_HDMI_CEC_UNAVAILABLE @@ -95,7 +97,7 @@ def type_id(self): return self._device.type @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" state_attr = {} if self.vendor_id is not None: diff --git a/homeassistant/components/heatmiser/climate.py b/homeassistant/components/heatmiser/climate.py index f44156bdcb0ae7..c6d10bc72b2490 100644 --- a/homeassistant/components/heatmiser/climate.py +++ b/homeassistant/components/heatmiser/climate.py @@ -55,8 +55,6 @@ def setup_platform( ) -> None: """Set up the heatmiser thermostat.""" - heatmiser_v3_thermostat = heatmiser.HeatmiserThermostat - host = config[CONF_HOST] port = config[CONF_PORT] @@ -65,10 +63,7 @@ def setup_platform( uh1_hub = connection.HeatmiserUH1(host, port) add_entities( - [ - HeatmiserV3Thermostat(heatmiser_v3_thermostat, thermostat, uh1_hub) - for thermostat in thermostats - ], + [HeatmiserV3Thermostat(thermostat, uh1_hub) for thermostat in thermostats], True, ) @@ -83,44 +78,31 @@ class HeatmiserV3Thermostat(ClimateEntity): | ClimateEntityFeature.TURN_ON ) - def __init__(self, therm, device, uh1): + def __init__( + self, + device: dict[str, Any], + uh1: connection.HeatmiserUH1, + ) -> None: """Initialize the thermostat.""" - self.therm = therm(device[CONF_ID], "prt", uh1) + self.therm = heatmiser.HeatmiserThermostat(device[CONF_ID], "prt", uh1) self.uh1 = uh1 - self._name = device[CONF_NAME] - self._current_temperature = None - self._target_temperature = None + self._attr_name = device[CONF_NAME] self._id = device self.dcb = None self._attr_hvac_mode = HVACMode.HEAT - @property - def name(self): - """Return the name of the thermostat, if any.""" - return self._name - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._current_temperature - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._target_temperature - def set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: return - self._target_temperature = int(temperature) - self.therm.set_target_temp(self._target_temperature) + self._attr_target_temperature = int(temperature) + self.therm.set_target_temp(self._attr_target_temperature) def update(self) -> None: """Get the latest data.""" self.uh1.reopen() if not self.uh1.status: - _LOGGER.error("Failed to update device %s", self._name) + _LOGGER.error("Failed to update device %s", self.name) return self.dcb = self.therm.read_dcb() self._attr_temperature_unit = ( @@ -128,8 +110,8 @@ def update(self) -> None: if (self.therm.get_temperature_format() == "C") else UnitOfTemperature.FAHRENHEIT ) - self._current_temperature = int(self.therm.get_floor_temp()) - self._target_temperature = int(self.therm.get_target_temp()) + self._attr_current_temperature = int(self.therm.get_floor_temp()) + self._attr_target_temperature = int(self.therm.get_target_temp()) self._attr_hvac_mode = ( HVACMode.OFF if (int(self.therm.get_current_state()) == 0) diff --git a/homeassistant/components/hikvision/__init__.py b/homeassistant/components/hikvision/__init__.py index af4f788d255f6e..b6cb1e7617dbf0 100644 --- a/homeassistant/components/hikvision/__init__.py +++ b/homeassistant/components/hikvision/__init__.py @@ -117,13 +117,21 @@ def fetch_and_inject_nvr_events() -> None: # Map raw event type names to friendly names using SENSOR_MAP mapped_events: dict[str, list[int]] = {} for event_type, channels in nvr_events.items(): - friendly_name = SENSOR_MAP.get(event_type.lower(), event_type) + event_key = event_type.lower() + # Skip videoloss - used as watchdog by pyhik, not a real sensor + if event_key == "videoloss": + continue + friendly_name = SENSOR_MAP.get(event_key) + if friendly_name is None: + _LOGGER.debug("Skipping unmapped event type: %s", event_type) + continue if friendly_name in mapped_events: mapped_events[friendly_name].extend(channels) else: mapped_events[friendly_name] = list(channels) _LOGGER.debug("Mapped NVR events: %s", mapped_events) - camera.inject_events(mapped_events) + if mapped_events: + camera.inject_events(mapped_events) else: _LOGGER.debug( "No event triggers returned from %s. " diff --git a/homeassistant/components/hikvision/manifest.json b/homeassistant/components/hikvision/manifest.json index f96b2a32f41dfd..a22aaafcc0f181 100644 --- a/homeassistant/components/hikvision/manifest.json +++ b/homeassistant/components/hikvision/manifest.json @@ -7,6 +7,5 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["pyhik"], - "quality_scale": "legacy", "requirements": ["pyHik==0.4.2"] } diff --git a/homeassistant/components/hikvision/quality_scale.yaml b/homeassistant/components/hikvision/quality_scale.yaml new file mode 100644 index 00000000000000..68a83807d42f3b --- /dev/null +++ b/homeassistant/components/hikvision/quality_scale.yaml @@ -0,0 +1,75 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration uses local_push and does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: todo + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not provide additional actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration has no configuration parameters. + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: done + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: todo + diagnostics: todo + discovery: todo + discovery-update-info: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + 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: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/hikvisioncam/switch.py b/homeassistant/components/hikvisioncam/switch.py index aa16097f4026e3..85ad3ba2f7a730 100644 --- a/homeassistant/components/hikvisioncam/switch.py +++ b/homeassistant/components/hikvisioncam/switch.py @@ -19,8 +19,6 @@ CONF_PASSWORD, CONF_PORT, CONF_USERNAME, - STATE_OFF, - STATE_ON, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv @@ -79,19 +77,9 @@ class HikvisionMotionSwitch(SwitchEntity): def __init__(self, name, hikvision_cam): """Initialize the switch.""" - self._name = name + self._attr_name = name self._hikvision_cam = hikvision_cam - self._state = STATE_OFF - - @property - def name(self): - """Return the name of the device if any.""" - return self._name - - @property - def is_on(self): - """Return true if device is on.""" - return self._state == STATE_ON + self._attr_is_on = False def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" @@ -105,7 +93,5 @@ def turn_off(self, **kwargs: Any) -> None: def update(self) -> None: """Update Motion Detection state.""" - enabled = self._hikvision_cam.is_motion_detection_enabled() - _LOGGING.info("enabled: %s", enabled) - - self._state = STATE_ON if enabled else STATE_OFF + self._attr_is_on = self._hikvision_cam.is_motion_detection_enabled() + _LOGGING.info("enabled: %s", self._attr_is_on) diff --git a/homeassistant/components/history_stats/__init__.py b/homeassistant/components/history_stats/__init__.py index ab416a5a50cc55..762d36c0210520 100644 --- a/homeassistant/components/history_stats/__init__.py +++ b/homeassistant/components/history_stats/__init__.py @@ -5,6 +5,7 @@ from datetime import timedelta import logging +from homeassistant.components.sensor import CONF_STATE_CLASS, SensorStateClass from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID, CONF_STATE from homeassistant.core import HomeAssistant @@ -15,7 +16,14 @@ ) from homeassistant.helpers.template import Template -from .const import CONF_DURATION, CONF_END, CONF_START, PLATFORMS +from .const import ( + CONF_DURATION, + CONF_END, + CONF_MIN_STATE_DURATION, + CONF_START, + PLATFORMS, + SECTION_ADVANCED_SETTINGS, +) from .coordinator import HistoryStatsUpdateCoordinator from .data import HistoryStats @@ -35,8 +43,14 @@ async def async_setup_entry( end: str | None = entry.options.get(CONF_END) duration: timedelta | None = None + min_state_duration: timedelta if duration_dict := entry.options.get(CONF_DURATION): duration = timedelta(**duration_dict) + advanced_settings = entry.options.get(SECTION_ADVANCED_SETTINGS, {}) + if min_state_duration_dict := advanced_settings.get(CONF_MIN_STATE_DURATION): + min_state_duration = timedelta(**min_state_duration_dict) + else: + min_state_duration = timedelta(0) history_stats = HistoryStats( hass, @@ -45,6 +59,7 @@ async def async_setup_entry( Template(start, hass) if start else None, Template(end, hass) if end else None, duration, + min_state_duration, ) coordinator = HistoryStatsUpdateCoordinator(hass, history_stats, entry, entry.title) await coordinator.async_config_entry_first_refresh() @@ -105,6 +120,12 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> hass.config_entries.async_update_entry( config_entry, options=options, minor_version=2 ) + if config_entry.minor_version < 3: + # Set the state class to measurement for backward compatibility + options[CONF_STATE_CLASS] = SensorStateClass.MEASUREMENT + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=3 + ) _LOGGER.debug( "Migration to version %s.%s successful", diff --git a/homeassistant/components/history_stats/config_flow.py b/homeassistant/components/history_stats/config_flow.py index 9ffdee6830bfdd..fc48e3c8e74023 100644 --- a/homeassistant/components/history_stats/config_flow.py +++ b/homeassistant/components/history_stats/config_flow.py @@ -9,8 +9,10 @@ import voluptuous as vol from homeassistant.components import websocket_api +from homeassistant.components.sensor import CONF_STATE_CLASS, SensorStateClass from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_STATE, CONF_TYPE from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import section from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.schema_config_entry_flow import ( SchemaCommonFlowHandler, @@ -36,12 +38,15 @@ from .const import ( CONF_DURATION, CONF_END, + CONF_MIN_STATE_DURATION, CONF_PERIOD_KEYS, CONF_START, CONF_TYPE_KEYS, + CONF_TYPE_RATIO, CONF_TYPE_TIME, DEFAULT_NAME, DOMAIN, + SECTION_ADVANCED_SETTINGS, ) from .coordinator import HistoryStatsUpdateCoordinator from .data import HistoryStats @@ -101,10 +106,19 @@ async def get_state_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: async def get_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: """Return schema for options step.""" entity_id = handler.options[CONF_ENTITY_ID] - return _get_options_schema_with_entity_id(entity_id) - - -def _get_options_schema_with_entity_id(entity_id: str) -> vol.Schema: + conf_type = handler.options[CONF_TYPE] + return _get_options_schema_with_entity_id(entity_id, conf_type) + + +def _get_options_schema_with_entity_id(entity_id: str, type: str) -> vol.Schema: + state_class_options = ( + [SensorStateClass.MEASUREMENT] + if type == CONF_TYPE_RATIO + else [ + SensorStateClass.MEASUREMENT, + SensorStateClass.TOTAL_INCREASING, + ] + ) return vol.Schema( { vol.Optional(CONF_ENTITY_ID): EntitySelector( @@ -128,7 +142,26 @@ def _get_options_schema_with_entity_id(entity_id: str) -> vol.Schema: vol.Optional(CONF_START): TemplateSelector(), vol.Optional(CONF_END): TemplateSelector(), vol.Optional(CONF_DURATION): DurationSelector( - DurationSelectorConfig(enable_day=True, allow_negative=False) + DurationSelectorConfig(enable_day=True, allow_negative=False), + ), + vol.Optional(CONF_STATE_CLASS): SelectSelector( + SelectSelectorConfig( + options=state_class_options, + translation_key=CONF_STATE_CLASS, + mode=SelectSelectorMode.DROPDOWN, + ), + ), + vol.Optional(SECTION_ADVANCED_SETTINGS): section( + vol.Schema( + { + vol.Optional(CONF_MIN_STATE_DURATION): DurationSelector( + DurationSelectorConfig( + enable_day=True, allow_negative=False + ) + ), + } + ), + {"collapsed": True}, ), } ) @@ -158,7 +191,7 @@ def _get_options_schema_with_entity_id(entity_id: str) -> vol.Schema: class HistoryStatsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config flow for History stats.""" - MINOR_VERSION = 2 + MINOR_VERSION = 3 config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW @@ -201,6 +234,7 @@ async def ws_start_preview( config_entry = hass.config_entries.async_get_entry(flow_status["handler"]) entity_id = options[CONF_ENTITY_ID] name = options[CONF_NAME] + conf_type = options[CONF_TYPE] else: flow_status = hass.config_entries.options.async_get(msg["flow_id"]) config_entry = hass.config_entries.async_get_entry(flow_status["handler"]) @@ -208,6 +242,7 @@ async def ws_start_preview( raise HomeAssistantError("Config entry not found") entity_id = config_entry.options[CONF_ENTITY_ID] name = config_entry.options[CONF_NAME] + conf_type = config_entry.options[CONF_TYPE] @callback def async_preview_updated( @@ -233,7 +268,7 @@ def async_preview_updated( validated_data: Any = None try: - validated_data = (_get_options_schema_with_entity_id(entity_id))( + validated_data = (_get_options_schema_with_entity_id(entity_id, conf_type))( msg["user_input"] ) except vol.Invalid as ex: @@ -255,6 +290,9 @@ def async_preview_updated( start = validated_data.get(CONF_START) end = validated_data.get(CONF_END) duration = validated_data.get(CONF_DURATION) + advanced_settings = validated_data.get(SECTION_ADVANCED_SETTINGS, {}) + min_state_duration = advanced_settings.get(CONF_MIN_STATE_DURATION) + state_class = validated_data.get(CONF_STATE_CLASS) history_stats = HistoryStats( hass, @@ -263,6 +301,7 @@ def async_preview_updated( Template(start, hass) if start else None, Template(end, hass) if end else None, timedelta(**duration) if duration else None, + timedelta(**min_state_duration) if min_state_duration else timedelta(0), True, ) coordinator = HistoryStatsUpdateCoordinator(hass, history_stats, None, name, True) @@ -274,6 +313,7 @@ def async_preview_updated( name=name, unique_id=None, source_entity_id=entity_id, + state_class=state_class, ) preview_entity.hass = hass diff --git a/homeassistant/components/history_stats/const.py b/homeassistant/components/history_stats/const.py index 9e89ca1827ce80..d608f56f6d8aec 100644 --- a/homeassistant/components/history_stats/const.py +++ b/homeassistant/components/history_stats/const.py @@ -8,6 +8,7 @@ CONF_START = "start" CONF_END = "end" CONF_DURATION = "duration" +CONF_MIN_STATE_DURATION = "min_state_duration" CONF_PERIOD_KEYS = [CONF_START, CONF_END, CONF_DURATION] CONF_TYPE_TIME = "time" @@ -16,3 +17,5 @@ CONF_TYPE_KEYS = [CONF_TYPE_TIME, CONF_TYPE_RATIO, CONF_TYPE_COUNT] DEFAULT_NAME = "unnamed statistics" + +SECTION_ADVANCED_SETTINGS = "advanced_settings" diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py index 569483df687c29..9a88812342ede8 100644 --- a/homeassistant/components/history_stats/data.py +++ b/homeassistant/components/history_stats/data.py @@ -47,6 +47,7 @@ def __init__( start: Template | None, end: Template | None, duration: datetime.timedelta | None, + min_state_duration: datetime.timedelta, preview: bool = False, ) -> None: """Init the history stats manager.""" @@ -58,6 +59,7 @@ def __init__( self._has_recorder_data = False self._entity_states = set(entity_states) self._duration = duration + self._min_state_duration = min_state_duration.total_seconds() self._start = start self._end = end self._preview = preview @@ -243,18 +245,38 @@ def _async_compute_seconds_and_changes( ) break - if previous_state_matches: - elapsed += state_change_timestamp - last_state_change_timestamp - elif current_state_matches: - match_count += 1 + if not previous_state_matches and current_state_matches: + # We are entering a matching state. + # This marks the start of a new candidate block that may later + # qualify if it lasts at least min_state_duration. + last_state_change_timestamp = max( + start_timestamp, state_change_timestamp + ) + elif previous_state_matches and not current_state_matches: + # We are leaving a matching state. + # This closes the current matching block and allows to + # evaluate its total duration. + block_duration = state_change_timestamp - last_state_change_timestamp + if block_duration >= self._min_state_duration: + # The block lasted long enough so we increment match count + # and accumulate its duration. + elapsed += block_duration + match_count += 1 previous_state_matches = current_state_matches - last_state_change_timestamp = max(start_timestamp, state_change_timestamp) # Count time elapsed between last history state and end of measure if previous_state_matches: + # We are still inside a matching block at the end of the + # measurement window. This block has not been closed by a + # transition, so we evaluate it up to measure_end. measure_end = min(end_timestamp, now_timestamp) - elapsed += measure_end - last_state_change_timestamp + last_state_duration = max(0, measure_end - last_state_change_timestamp) + if last_state_duration >= self._min_state_duration: + # The open block lasted long enough so we increment match count + # and accumulate its duration. + elapsed += last_state_duration + match_count += 1 # Save value in seconds seconds_matched = elapsed diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index 1bd5d491e0c046..367f9892ca2be4 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant.components.sensor import ( + CONF_STATE_CLASS, PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, @@ -41,6 +42,7 @@ from .const import ( CONF_DURATION, CONF_END, + CONF_MIN_STATE_DURATION, CONF_PERIOD_KEYS, CONF_START, CONF_TYPE_COUNT, @@ -62,6 +64,8 @@ } ICON = "mdi:chart-line" +DEFAULT_MIN_STATE_DURATION = datetime.timedelta(0) + def exactly_two_period_keys[_T: dict[str, Any]](conf: _T) -> _T: """Ensure exactly 2 of CONF_PERIOD_KEYS are provided.""" @@ -72,6 +76,16 @@ def exactly_two_period_keys[_T: dict[str, Any]](conf: _T) -> _T: return conf +def no_ratio_total[_T: dict[str, Any]](conf: _T) -> _T: + """Ensure state_class:total_increasing not used with type:ratio.""" + if ( + conf.get(CONF_TYPE) == CONF_TYPE_RATIO + and conf.get(CONF_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING + ): + raise vol.Invalid("State class total_increasing not to be used with type ratio") + return conf + + PLATFORM_SCHEMA = vol.All( SENSOR_PLATFORM_SCHEMA.extend( { @@ -80,12 +94,21 @@ def exactly_two_period_keys[_T: dict[str, Any]](conf: _T) -> _T: vol.Optional(CONF_START): cv.template, vol.Optional(CONF_END): cv.template, vol.Optional(CONF_DURATION): cv.time_period, + vol.Optional( + CONF_MIN_STATE_DURATION, default=DEFAULT_MIN_STATE_DURATION + ): cv.time_period, vol.Optional(CONF_TYPE, default=CONF_TYPE_TIME): vol.In(CONF_TYPE_KEYS), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional( + CONF_STATE_CLASS, default=SensorStateClass.MEASUREMENT + ): vol.In( + [None, SensorStateClass.MEASUREMENT, SensorStateClass.TOTAL_INCREASING] + ), } ), exactly_two_period_keys, + no_ratio_total, ) @@ -103,11 +126,17 @@ async def async_setup_platform( start: Template | None = config.get(CONF_START) end: Template | None = config.get(CONF_END) duration: datetime.timedelta | None = config.get(CONF_DURATION) + min_state_duration: datetime.timedelta = config[CONF_MIN_STATE_DURATION] sensor_type: str = config[CONF_TYPE] name: str = config[CONF_NAME] unique_id: str | None = config.get(CONF_UNIQUE_ID) + state_class: SensorStateClass | None = config.get( + CONF_STATE_CLASS, SensorStateClass.MEASUREMENT + ) - history_stats = HistoryStats(hass, entity_id, entity_states, start, end, duration) + history_stats = HistoryStats( + hass, entity_id, entity_states, start, end, duration, min_state_duration + ) coordinator = HistoryStatsUpdateCoordinator(hass, history_stats, None, name) await coordinator.async_refresh() if not coordinator.last_update_success: @@ -121,6 +150,7 @@ async def async_setup_platform( name=name, unique_id=unique_id, source_entity_id=entity_id, + state_class=state_class, ) ] ) @@ -136,6 +166,7 @@ async def async_setup_entry( sensor_type: str = entry.options[CONF_TYPE] coordinator = entry.runtime_data entity_id: str = entry.options[CONF_ENTITY_ID] + state_class: SensorStateClass | None = entry.options.get(CONF_STATE_CLASS) async_add_entities( [ HistoryStatsSensor( @@ -145,6 +176,7 @@ async def async_setup_entry( name=entry.title, unique_id=entry.entry_id, source_entity_id=entity_id, + state_class=state_class, ) ] ) @@ -185,8 +217,6 @@ def _process_update(self) -> None: class HistoryStatsSensor(HistoryStatsSensorBase): """A HistoryStats sensor.""" - _attr_state_class = SensorStateClass.MEASUREMENT - def __init__( self, hass: HomeAssistant, @@ -196,6 +226,7 @@ def __init__( name: str, unique_id: str | None, source_entity_id: str, + state_class: SensorStateClass | None, ) -> None: """Initialize the HistoryStats sensor.""" super().__init__(coordinator, name) @@ -204,6 +235,7 @@ def __init__( ) = None self._attr_native_unit_of_measurement = UNITS[sensor_type] self._type = sensor_type + self._attr_state_class = state_class self._attr_unique_id = unique_id if source_entity_id: # Guard against empty source_entity_id in preview mode self.device_entry = async_entity_id_to_device( diff --git a/homeassistant/components/history_stats/strings.json b/homeassistant/components/history_stats/strings.json index d08e1ec4329ec5..584456484fc444 100644 --- a/homeassistant/components/history_stats/strings.json +++ b/homeassistant/components/history_stats/strings.json @@ -14,17 +14,28 @@ "entity_id": "[%key:component::history_stats::config::step::user::data::entity_id%]", "start": "Start", "state": "[%key:component::history_stats::config::step::user::data::state%]", + "state_class": "[%key:component::sensor::entity_component::_::state_attributes::state_class::name%]", "type": "[%key:component::history_stats::config::step::user::data::type%]" }, "data_description": { "duration": "Duration of the measure.", - "end": "When to stop the measure (timestamp or datetime). Can be a template", + "end": "When to stop the measure (timestamp or datetime). Can be a template.", "entity_id": "[%key:component::history_stats::config::step::user::data_description::entity_id%]", "start": "When to start the measure (timestamp or datetime). Can be a template.", "state": "[%key:component::history_stats::config::step::user::data_description::state%]", + "state_class": "The state class for statistics calculation.", "type": "[%key:component::history_stats::config::step::user::data_description::type%]" }, - "description": "Read the documentation for further details on how to configure the history stats sensor using these options." + "description": "Read the documentation for further details on how to configure the history stats sensor using these options.", + "sections": { + "advanced_settings": { + "data": { "min_state_duration": "Minimum state duration" }, + "data_description": { + "min_state_duration": "The minimum state duration to account for the statistics. Default is 0 seconds." + }, + "name": "Advanced settings" + } + } }, "state": { "data": { @@ -68,6 +79,7 @@ "entity_id": "[%key:component::history_stats::config::step::user::data::entity_id%]", "start": "[%key:component::history_stats::config::step::options::data::start%]", "state": "[%key:component::history_stats::config::step::user::data::state%]", + "state_class": "[%key:component::sensor::entity_component::_::state_attributes::state_class::name%]", "type": "[%key:component::history_stats::config::step::user::data::type%]" }, "data_description": { @@ -76,13 +88,31 @@ "entity_id": "[%key:component::history_stats::config::step::user::data_description::entity_id%]", "start": "[%key:component::history_stats::config::step::options::data_description::start%]", "state": "[%key:component::history_stats::config::step::user::data_description::state%]", + "state_class": "The state class for statistics calculation. Changing the state class will require statistics to be reset.", "type": "[%key:component::history_stats::config::step::user::data_description::type%]" }, - "description": "[%key:component::history_stats::config::step::options::description%]" + "description": "[%key:component::history_stats::config::step::options::description%]", + "sections": { + "advanced_settings": { + "data": { + "min_state_duration": "[%key:component::history_stats::config::step::options::sections::advanced_settings::data::min_state_duration%]" + }, + "data_description": { + "min_state_duration": "[%key:component::history_stats::config::step::options::sections::advanced_settings::data_description::min_state_duration%]" + }, + "name": "[%key:component::history_stats::config::step::options::sections::advanced_settings::name%]" + } + } } } }, "selector": { + "state_class": { + "options": { + "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", + "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" + } + }, "type": { "options": { "count": "Count", diff --git a/homeassistant/components/hive/config_flow.py b/homeassistant/components/hive/config_flow.py index 41dba27c3a5cc9..3e2d02f153c590 100644 --- a/homeassistant/components/hive/config_flow.py +++ b/homeassistant/components/hive/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any from apyhiveapi import Auth @@ -26,6 +27,8 @@ from . import HiveConfigEntry from .const import CONF_CODE, CONF_DEVICE_NAME, CONFIG_ENTRY_VERSION, DOMAIN +_LOGGER = logging.getLogger(__name__) + class HiveFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a Hive config flow.""" @@ -36,7 +39,7 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the config flow.""" self.data: dict[str, Any] = {} - self.tokens: dict[str, str] = {} + self.tokens: dict[str, Any] = {} self.device_registration: bool = False self.device_name = "Home Assistant" @@ -67,11 +70,22 @@ async def async_step_user( except HiveApiError: errors["base"] = "no_internet_available" + if ( + auth_result := self.tokens.get("AuthenticationResult", {}) + ) and auth_result.get("NewDeviceMetadata"): + _LOGGER.debug("Login successful, New device detected") + self.device_registration = True + return await self.async_step_configuration() + if self.tokens.get("ChallengeName") == "SMS_MFA": + _LOGGER.debug("Login successful, SMS 2FA required") # Complete SMS 2FA. return await self.async_step_2fa() if not errors: + _LOGGER.debug( + "Login successful, no new device detected, no 2FA required" + ) # Complete the entry. try: return await self.async_setup_hive_entry() @@ -103,6 +117,7 @@ async def async_step_2fa( errors["base"] = "no_internet_available" if not errors: + _LOGGER.debug("2FA successful") if self.source == SOURCE_REAUTH: return await self.async_setup_hive_entry() self.device_registration = True @@ -119,10 +134,11 @@ async def async_step_configuration( if user_input: if self.device_registration: + _LOGGER.debug("Attempting to register device") self.device_name = user_input["device_name"] await self.hive_auth.device_registration(user_input["device_name"]) self.data["device_data"] = await self.hive_auth.get_device_data() - + _LOGGER.debug("Device registration successful") try: return await self.async_setup_hive_entry() except UnknownHiveError: @@ -142,6 +158,7 @@ async def async_setup_hive_entry(self) -> ConfigFlowResult: raise UnknownHiveError # Setup the config entry + _LOGGER.debug("Setting up Hive entry") self.data["tokens"] = self.tokens if self.source == SOURCE_REAUTH: return self.async_update_reload_and_abort( @@ -160,6 +177,7 @@ async def async_step_reauth( CONF_USERNAME: entry_data[CONF_USERNAME], CONF_PASSWORD: entry_data[CONF_PASSWORD], } + _LOGGER.debug("Reauthenticating user") return await self.async_step_user(data) @staticmethod diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json index a97be87c5974f0..a03bf9279cb8dc 100644 --- a/homeassistant/components/hive/manifest.json +++ b/homeassistant/components/hive/manifest.json @@ -10,5 +10,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["apyhiveapi"], - "requirements": ["pyhive-integration==1.0.7"] + "requirements": ["pyhive-integration==1.0.8"] } diff --git a/homeassistant/components/hko/coordinator.py b/homeassistant/components/hko/coordinator.py index 29746c20728201..a2c47a765db8dd 100644 --- a/homeassistant/components/hko/coordinator.py +++ b/homeassistant/components/hko/coordinator.py @@ -119,7 +119,7 @@ def _convert_current(self, data: dict[str, Any]) -> dict[str, Any]: for item in data[API_TEMPERATURE][API_DATA] if item[API_PLACE] == self.location ), - 0, + None, ), } diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 9ea7da02b8797d..91c66a4db56e1b 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -6,13 +6,18 @@ from typing import Any from aiohomeconnect.client import Client as HomeConnectClient +from aiohomeconnect.model import EventKey import aiohttp import jwt from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + issue_registry as ir, +) from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, OAuth2Session, @@ -23,7 +28,7 @@ from .api import AsyncConfigEntryAuth from .const import DOMAIN, OLD_NEW_UNIQUE_ID_SUFFIX_MAP -from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator +from .coordinator import HomeConnectConfigEntry, HomeConnectRuntimeData from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -33,6 +38,8 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.CLIMATE, + Platform.FAN, Platform.LIGHT, Platform.NUMBER, Platform.SELECT, @@ -71,19 +78,46 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry) home_connect_client = HomeConnectClient(config_entry_auth) - coordinator = HomeConnectCoordinator(hass, entry, home_connect_client) - await coordinator.async_setup() - entry.runtime_data = coordinator + runtime_data = HomeConnectRuntimeData(hass, entry, home_connect_client) + await runtime_data.setup_appliance_coordinators() + entry.runtime_data = runtime_data + + appliances_identifiers = { + (entry.domain, ha_id) for ha_id in entry.runtime_data.appliance_coordinators + } + device_registry = dr.async_get(hass) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry_id=entry.entry_id + ) + + for device in device_entries: + if not device.identifiers.intersection(appliances_identifiers): + device_registry.async_update_device( + device.id, remove_config_entry_id=entry.entry_id + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + for listener, context in runtime_data.global_listeners.values(): + # We call the PAIRED event listener to start adding entities + # from the appliances we already found above + assert isinstance(context, tuple) + if EventKey.BSH_COMMON_APPLIANCE_PAIRED in context: + listener() + entry.runtime_data.start_event_listener() - entry.async_create_background_task( - hass, - coordinator.async_refresh(), - f"home_connect-initial-full-refresh-{entry.entry_id}", - ) + for ( + appliance_id, + appliance_coordinator, + ) in entry.runtime_data.appliance_coordinators.items(): + # We refresh each appliance coordinator in the background. + # to ensure that setup time is not impacted by this refresh. + entry.async_create_background_task( + hass, + appliance_coordinator.async_refresh(), + f"home_connect-initial-full-refresh-{entry.entry_id}-{appliance_id}", + ) return True @@ -104,6 +138,9 @@ async def async_unload_entry( ] for issue_id in issues_to_delete: issue_registry.async_delete(DOMAIN, issue_id) + + for coordinator in entry.runtime_data.appliance_coordinators.values(): + await coordinator.async_shutdown() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index 3f32fbca5bd8a3..f2cc4b067fce49 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -16,7 +16,7 @@ from .common import setup_home_connect_entry from .const import REFRIGERATION_STATUS_DOOR_CLOSED, REFRIGERATION_STATUS_DOOR_OPEN -from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry +from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry from .entity import HomeConnectEntity PARALLEL_UPDATES = 0 @@ -145,19 +145,18 @@ class HomeConnectBinarySensorEntityDescription(BinarySensorEntityDescription): def _get_entities_for_appliance( - entry: HomeConnectConfigEntry, - appliance: HomeConnectApplianceData, + appliance_coordinator: HomeConnectApplianceCoordinator, ) -> list[HomeConnectEntity]: """Get a list of entities.""" entities: list[HomeConnectEntity] = [ HomeConnectConnectivityBinarySensor( - entry.runtime_data, appliance, CONNECTED_BINARY_ENTITY_DESCRIPTION + appliance_coordinator, CONNECTED_BINARY_ENTITY_DESCRIPTION ) ] entities.extend( - HomeConnectBinarySensor(entry.runtime_data, appliance, description) + HomeConnectBinarySensor(appliance_coordinator, description) for description in BINARY_SENSORS - if description.key in appliance.status + if description.key in appliance_coordinator.data.status ) return entities diff --git a/homeassistant/components/home_connect/button.py b/homeassistant/components/home_connect/button.py index 8e07c2c8622f16..529167570239f5 100644 --- a/homeassistant/components/home_connect/button.py +++ b/homeassistant/components/home_connect/button.py @@ -10,11 +10,7 @@ from .common import setup_home_connect_entry from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN -from .coordinator import ( - HomeConnectApplianceData, - HomeConnectConfigEntry, - HomeConnectCoordinator, -) +from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry from .entity import HomeConnectEntity from .utils import get_dict_from_home_connect_error @@ -48,20 +44,18 @@ class HomeConnectCommandButtonEntityDescription(ButtonEntityDescription): def _get_entities_for_appliance( - entry: HomeConnectConfigEntry, - appliance: HomeConnectApplianceData, + appliance_coordinator: HomeConnectApplianceCoordinator, ) -> list[HomeConnectEntity]: """Get a list of entities.""" entities: list[HomeConnectEntity] = [] + appliance_data = appliance_coordinator.data entities.extend( - HomeConnectCommandButtonEntity(entry.runtime_data, appliance, description) + HomeConnectCommandButtonEntity(appliance_coordinator, description) for description in COMMAND_BUTTONS - if description.key in appliance.commands + if description.key in appliance_data.commands ) - if appliance.info.type in APPLIANCES_WITH_PROGRAMS: - entities.append( - HomeConnectStopProgramButtonEntity(entry.runtime_data, appliance) - ) + if appliance_data.info.type in APPLIANCES_WITH_PROGRAMS: + entities.append(HomeConnectStopProgramButtonEntity(appliance_coordinator)) return entities @@ -87,17 +81,11 @@ class HomeConnectButtonEntity(HomeConnectEntity, ButtonEntity): def __init__( self, - coordinator: HomeConnectCoordinator, - appliance: HomeConnectApplianceData, + appliance_coordinator: HomeConnectApplianceCoordinator, desc: ButtonEntityDescription, ) -> None: """Initialize the entity.""" - super().__init__( - coordinator, - appliance, - desc, - (appliance.info.ha_id,), - ) + super().__init__(appliance_coordinator, desc, context_override=True) def update_native_value(self) -> None: """Set the value of the entity.""" @@ -130,15 +118,10 @@ async def async_press(self) -> None: class HomeConnectStopProgramButtonEntity(HomeConnectButtonEntity): """Button entity for stopping a program.""" - def __init__( - self, - coordinator: HomeConnectCoordinator, - appliance: HomeConnectApplianceData, - ) -> None: + def __init__(self, appliance_coordinator: HomeConnectApplianceCoordinator) -> None: """Initialize the entity.""" super().__init__( - coordinator, - appliance, + appliance_coordinator, ButtonEntityDescription( key="StopProgram", translation_key="stop_program", diff --git a/homeassistant/components/home_connect/climate.py b/homeassistant/components/home_connect/climate.py new file mode 100644 index 00000000000000..eda016342e5d7f --- /dev/null +++ b/homeassistant/components/home_connect/climate.py @@ -0,0 +1,325 @@ +"""Provides climate entities for Home Connect.""" + +import logging +from typing import Any, cast + +from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey +from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.program import Execution + +from homeassistant.components.climate import ( + FAN_AUTO, + ClimateEntity, + ClimateEntityDescription, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .common import setup_home_connect_entry +from .const import BSH_POWER_ON, BSH_POWER_STANDBY, DOMAIN +from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry +from .entity import HomeConnectEntity +from .utils import get_dict_from_home_connect_error + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 1 + +HVAC_MODES_PROGRAMS_MAP = { + HVACMode.AUTO: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO, + HVACMode.COOL: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_COOL, + HVACMode.DRY: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_DRY, + HVACMode.FAN_ONLY: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN, + HVACMode.HEAT: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_HEAT, +} + +PROGRAMS_HVAC_MODES_MAP = {v: k for k, v in HVAC_MODES_PROGRAMS_MAP.items()} + +PRESET_MODES_PROGRAMS_MAP = { + "active_clean": ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN, +} +PROGRAMS_PRESET_MODES_MAP = {v: k for k, v in PRESET_MODES_PROGRAMS_MAP.items()} + +FAN_MODES_OPTIONS = { + FAN_AUTO: "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic", + "manual": "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual", +} + +FAN_MODES_OPTIONS_INVERTED = {v: k for k, v in FAN_MODES_OPTIONS.items()} + + +AIR_CONDITIONER_ENTITY_DESCRIPTION = ClimateEntityDescription( + key="air_conditioner", + translation_key="air_conditioner", + name=None, +) + + +def _get_entities_for_appliance( + appliance_coordinator: HomeConnectApplianceCoordinator, +) -> list[HomeConnectEntity]: + """Get a list of entities.""" + return ( + [HomeConnectAirConditioningEntity(appliance_coordinator)] + if (programs := appliance_coordinator.data.programs) + and any( + program.key in PROGRAMS_HVAC_MODES_MAP + and ( + program.constraints is None + or program.constraints.execution + in (Execution.SELECT_AND_START, Execution.START_ONLY) + ) + for program in programs + ) + else [] + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HomeConnectConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Home Connect climate entities.""" + setup_home_connect_entry( + hass, + entry, + _get_entities_for_appliance, + async_add_entities, + ) + + +class HomeConnectAirConditioningEntity(HomeConnectEntity, ClimateEntity): + """Representation of a Home Connect climate entity.""" + + # Note: The base class requires this to be set even though this + # class doesn't support any temperature related functionality. + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + def __init__( + self, + coordinator: HomeConnectApplianceCoordinator, + ) -> None: + """Initialize the entity.""" + super().__init__( + coordinator, + AIR_CONDITIONER_ENTITY_DESCRIPTION, + context_override=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + ) + + @property + def hvac_modes(self) -> list[HVACMode]: + """Return the list of available hvac operation modes.""" + hvac_modes = [ + hvac_mode + for program in self.appliance.programs + if (hvac_mode := PROGRAMS_HVAC_MODES_MAP.get(program.key)) + and ( + program.constraints is None + or program.constraints.execution + in (Execution.SELECT_AND_START, Execution.START_ONLY) + ) + ] + if SettingKey.BSH_COMMON_POWER_STATE in self.appliance.settings: + hvac_modes.append(HVACMode.OFF) + return hvac_modes + + @property + def preset_modes(self) -> list[str] | None: + """Return a list of available preset modes.""" + return ( + [ + PROGRAMS_PRESET_MODES_MAP[ + ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN + ] + ] + if any( + program.key + is ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN + for program in self.appliance.programs + ) + else None + ) + + @property + def supported_features(self) -> ClimateEntityFeature: + """Return the list of supported features.""" + features = ClimateEntityFeature(0) + if SettingKey.BSH_COMMON_POWER_STATE in self.appliance.settings: + features |= ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF + if self.preset_modes: + features |= ClimateEntityFeature.PRESET_MODE + if self.appliance.options.get( + OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE + ): + features |= ClimateEntityFeature.FAN_MODE + return features + + @callback + def _handle_coordinator_update_fan_mode(self) -> None: + """Handle updated data from the coordinator.""" + self.async_write_ha_state() + _LOGGER.debug( + "Updated %s (fan mode), new state: %s", self.entity_id, self.fan_mode + ) + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.async_add_listener( + self.async_write_ha_state, + EventKey.BSH_COMMON_APPLIANCE_CONNECTED, + ) + ) + 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, + ) + ) + self.async_on_remove( + self.coordinator.async_add_listener( + self._handle_coordinator_update, + EventKey(SettingKey.BSH_COMMON_POWER_STATE), + ) + ) + + def update_native_value(self) -> None: + """Set the HVAC Mode and preset mode values.""" + event = self.appliance.events.get(EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM) + program_key = cast(ProgramKey, event.value) if event else None + power_state = self.appliance.settings.get(SettingKey.BSH_COMMON_POWER_STATE) + self._attr_hvac_mode = ( + HVACMode.OFF + if power_state is not None and power_state.value != BSH_POWER_ON + else PROGRAMS_HVAC_MODES_MAP.get(program_key) + if program_key + and program_key + != ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN + else None + ) + self._attr_preset_mode = ( + PROGRAMS_PRESET_MODES_MAP.get(program_key) + if program_key + == ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN + else None + ) + + @property + 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 + ) + ): + option_value = event.value + return ( + FAN_MODES_OPTIONS_INVERTED.get(cast(str, option_value)) + if option_value is not None + else None + ) + + @property + def fan_modes(self) -> list[str] | None: + """Return the list of available fan modes.""" + if ( + ( + option_definition := self.appliance.options.get( + OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE + ) + ) + and (option_constraints := option_definition.constraints) + and option_constraints.allowed_values + ): + return [ + fan_mode + for fan_mode, api_value in FAN_MODES_OPTIONS.items() + if api_value in option_constraints.allowed_values + ] + if option_definition: + # Then the constraints or the allowed values are not present + # So we stick to the default values + return list(FAN_MODES_OPTIONS.keys()) + return None + + async def async_turn_on(self, **kwargs: Any) -> None: + """Switch the device on.""" + try: + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey.BSH_COMMON_POWER_STATE, + value=BSH_POWER_ON, + ) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="power_on", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + "appliance_name": self.appliance.info.name, + "value": BSH_POWER_ON, + }, + ) from err + + async def async_turn_off(self, **kwargs: Any) -> None: + """Switch the device off.""" + try: + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey.BSH_COMMON_POWER_STATE, + value=BSH_POWER_STANDBY, + ) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="power_off", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + "appliance_name": self.appliance.info.name, + "value": BSH_POWER_STANDBY, + }, + ) from err + + async def _set_program(self, program_key: ProgramKey) -> None: + try: + await self.coordinator.client.start_program( + self.appliance.info.ha_id, program_key=program_key + ) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="start_program", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + "program": program_key.value, + }, + ) from err + _LOGGER.debug("Updated %s, new state: %s", self.entity_id, self.state) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + if hvac_mode is HVACMode.OFF: + await self.async_turn_off() + else: + await self._set_program(HVAC_MODES_PROGRAMS_MAP[hvac_mode]) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + await self._set_program(PRESET_MODES_PROGRAMS_MAP[preset_mode]) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + await super().async_set_option_with_key( + OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE, + FAN_MODES_OPTIONS[fan_mode], + ) + _LOGGER.debug( + "Updated %s's speed mode option, new state: %s", self.entity_id, self.state + ) diff --git a/homeassistant/components/home_connect/common.py b/homeassistant/components/home_connect/common.py index 8e40ade8b2147d..61e9e56016e4a9 100644 --- a/homeassistant/components/home_connect/common.py +++ b/homeassistant/components/home_connect/common.py @@ -14,8 +14,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN -from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry -from .entity import HomeConnectEntity, HomeConnectOptionEntity +from .coordinator import ( + HomeConnectApplianceCoordinator, + HomeConnectApplianceData, + HomeConnectConfigEntry, +) +from .entity import HomeConnectEntity def should_add_option_entity( @@ -40,12 +44,11 @@ def should_add_option_entity( def _create_option_entities( entity_registry: er.EntityRegistry, - entry: HomeConnectConfigEntry, - appliance: HomeConnectApplianceData, + appliance_coordinator: HomeConnectApplianceCoordinator, known_entity_unique_ids: dict[str, str], get_option_entities_for_appliance: Callable[ - [HomeConnectConfigEntry, HomeConnectApplianceData, er.EntityRegistry], - list[HomeConnectOptionEntity], + [HomeConnectApplianceCoordinator, er.EntityRegistry], + list[HomeConnectEntity], ], async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: @@ -53,13 +56,13 @@ def _create_option_entities( option_entities_to_add = [ entity for entity in get_option_entities_for_appliance( - entry, appliance, entity_registry + appliance_coordinator, entity_registry ) if entity.unique_id not in known_entity_unique_ids ] known_entity_unique_ids.update( { - cast(str, entity.unique_id): appliance.info.ha_id + cast(str, entity.unique_id): appliance_coordinator.data.info.ha_id for entity in option_entities_to_add } ) @@ -71,11 +74,11 @@ def _handle_paired_or_connected_appliance( entry: HomeConnectConfigEntry, known_entity_unique_ids: dict[str, str], get_entities_for_appliance: Callable[ - [HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity] + [HomeConnectApplianceCoordinator], list[HomeConnectEntity] ], get_option_entities_for_appliance: Callable[ - [HomeConnectConfigEntry, HomeConnectApplianceData, er.EntityRegistry], - list[HomeConnectOptionEntity], + [HomeConnectApplianceCoordinator, er.EntityRegistry], + list[HomeConnectEntity], ] | None, changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]], @@ -90,17 +93,18 @@ def _handle_paired_or_connected_appliance( """ entities: list[HomeConnectEntity] = [] entity_registry = er.async_get(hass) - for appliance in entry.runtime_data.data.values(): + for appliance_coordinator in entry.runtime_data.appliance_coordinators.values(): + appliance_ha_id = appliance_coordinator.data.info.ha_id entities_to_add = [ entity - for entity in get_entities_for_appliance(entry, appliance) + for entity in get_entities_for_appliance(appliance_coordinator) if entity.unique_id not in known_entity_unique_ids ] if get_option_entities_for_appliance: entities_to_add.extend( entity for entity in get_option_entities_for_appliance( - entry, appliance, entity_registry + appliance_coordinator, entity_registry ) if entity.unique_id not in known_entity_unique_ids ) @@ -109,28 +113,24 @@ def _handle_paired_or_connected_appliance( EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, ): changed_options_listener_remove_callback = ( - entry.runtime_data.async_add_listener( + appliance_coordinator.async_add_listener( partial( _create_option_entities, entity_registry, - entry, - appliance, + appliance_coordinator, known_entity_unique_ids, get_option_entities_for_appliance, async_add_entities, ), - (appliance.info.ha_id, event_key), + event_key, ) ) entry.async_on_unload(changed_options_listener_remove_callback) - changed_options_listener_remove_callbacks[appliance.info.ha_id].append( + changed_options_listener_remove_callbacks[appliance_ha_id].append( changed_options_listener_remove_callback ) known_entity_unique_ids.update( - { - cast(str, entity.unique_id): appliance.info.ha_id - for entity in entities_to_add - } + {cast(str, entity.unique_id): appliance_ha_id for entity in entities_to_add} ) entities.extend(entities_to_add) async_add_entities(entities) @@ -143,7 +143,7 @@ def _handle_depaired_appliance( ) -> None: """Handle a removed appliance.""" for entity_unique_id, appliance_id in known_entity_unique_ids.copy().items(): - if appliance_id not in entry.runtime_data.data: + if appliance_id not in entry.runtime_data.appliance_coordinators: known_entity_unique_ids.pop(entity_unique_id, None) if appliance_id in changed_options_listener_remove_callbacks: for listener in changed_options_listener_remove_callbacks.pop( @@ -156,12 +156,12 @@ def setup_home_connect_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, get_entities_for_appliance: Callable[ - [HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity] + [HomeConnectApplianceCoordinator], list[HomeConnectEntity] ], async_add_entities: AddConfigEntryEntitiesCallback, get_option_entities_for_appliance: Callable[ - [HomeConnectConfigEntry, HomeConnectApplianceData, er.EntityRegistry], - list[HomeConnectOptionEntity], + [HomeConnectApplianceCoordinator, er.EntityRegistry], + list[HomeConnectEntity], ] | None = None, ) -> None: @@ -172,7 +172,7 @@ def setup_home_connect_entry( ) entry.async_on_unload( - entry.runtime_data.async_add_special_listener( + entry.runtime_data.async_add_global_listener( partial( _handle_paired_or_connected_appliance, hass, @@ -190,7 +190,7 @@ def setup_home_connect_entry( ) ) entry.async_on_unload( - entry.runtime_data.async_add_special_listener( + entry.runtime_data.async_add_global_listener( partial( _handle_depaired_appliance, entry, diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 623a65ade36998..9090859456de9e 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -1,7 +1,5 @@ """Constants for the Home Connect integration.""" -from typing import cast - from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey, StatusKey from homeassistant.const import UnitOfTemperature, UnitOfTime, UnitOfVolume @@ -65,6 +63,7 @@ SERVICE_SET_PROGRAM_AND_OPTIONS = "set_program_and_options" SERVICE_SETTING = "change_setting" +SERVICE_START_SELECTED_PROGRAM = "start_selected_program" ATTR_AFFECTS_TO = "affects_to" ATTR_KEY = "key" @@ -76,9 +75,9 @@ TRANSLATION_KEYS_PROGRAMS_MAP = { - bsh_key_to_translation_key(program.value): cast(ProgramKey, program) + bsh_key_to_translation_key(program.value): program for program in ProgramKey - if program != ProgramKey.UNKNOWN + if program not in (ProgramKey.UNKNOWN, ProgramKey.BSH_COMMON_FAVORITE_001) } PROGRAMS_TRANSLATION_KEYS_MAP = { diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 10b19d2c42729d..f9f084ba2e7140 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -3,11 +3,9 @@ from __future__ import annotations from asyncio import sleep as asyncio_sleep -from collections import defaultdict from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Any from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.model import ( @@ -33,7 +31,6 @@ UnauthorizedError, ) from aiohomeconnect.model.program import EnumerateProgram, ProgramDefinitionOption -from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback @@ -54,7 +51,7 @@ MAX_EXECUTIONS_TIME_WINDOW = 60 * 60 # 1 hour MAX_EXECUTIONS = 8 -type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator] +type HomeConnectConfigEntry = ConfigEntry[HomeConnectRuntimeData] @dataclass(frozen=True, kw_only=True) @@ -96,12 +93,14 @@ def empty(cls, appliance: HomeAppliance) -> HomeConnectApplianceData: ) -class HomeConnectCoordinator( - DataUpdateCoordinator[dict[str, HomeConnectApplianceData]] -): - """Class to manage fetching Home Connect data.""" +class HomeConnectRuntimeData: + """Class to manage Home Connect's integration runtime data. + + It also handles the API server-sent events. + """ config_entry: HomeConnectConfigEntry + appliance_coordinators: dict[str, HomeConnectApplianceCoordinator] def __init__( self, @@ -110,64 +109,14 @@ def __init__( client: HomeConnectClient, ) -> None: """Initialize.""" - super().__init__( - hass, - _LOGGER, - config_entry=config_entry, - name=config_entry.entry_id, - ) + self.hass = hass + self.config_entry = config_entry self.client = client - self._special_listeners: dict[ + self.global_listeners: dict[ CALLBACK_TYPE, tuple[CALLBACK_TYPE, tuple[EventKey, ...]] ] = {} self.device_registry = dr.async_get(self.hass) - self.data = {} - self._execution_tracker: dict[str, list[float]] = defaultdict(list) - - @cached_property - def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]: - """Return a dict of all listeners registered for a given context.""" - listeners: dict[tuple[str, EventKey], list[CALLBACK_TYPE]] = defaultdict(list) - for listener, context in list(self._listeners.values()): - assert isinstance(context, tuple) - listeners[context].append(listener) - return listeners - - @callback - def async_add_listener( - self, update_callback: CALLBACK_TYPE, context: Any = None - ) -> Callable[[], None]: - """Listen for data updates.""" - remove_listener = super().async_add_listener(update_callback, context) - self.__dict__.pop("context_listeners", None) - - def remove_listener_and_invalidate_context_listeners() -> None: - remove_listener() - self.__dict__.pop("context_listeners", None) - - return remove_listener_and_invalidate_context_listeners - - @callback - def async_add_special_listener( - self, - update_callback: CALLBACK_TYPE, - context: tuple[EventKey, ...], - ) -> Callable[[], None]: - """Listen for special data updates. - - These listeners will not be called on refresh. - """ - - @callback - def remove_listener() -> None: - """Remove update listener.""" - self._special_listeners.pop(remove_listener) - if not self._special_listeners: - self._unschedule_refresh() - - self._special_listeners[remove_listener] = (update_callback, context) - - return remove_listener + self.appliance_coordinators = {} @callback def start_event_listener(self) -> None: @@ -178,7 +127,7 @@ def start_event_listener(self) -> None: f"home_connect-events_listener_task-{self.config_entry.entry_id}", ) - async def _event_listener(self) -> None: # noqa: C901 + async def _event_listener(self) -> None: """Match event with listener for event type.""" retry_time = 10 while True: @@ -186,129 +135,37 @@ async def _event_listener(self) -> None: # noqa: C901 async for event_message in self.client.stream_all_events(): retry_time = 10 event_message_ha_id = event_message.ha_id - if ( - event_message_ha_id in self.data - and not self.data[event_message_ha_id].info.connected - ): - self.data[event_message_ha_id].info.connected = True - self._call_all_event_listeners_for_appliance( - event_message_ha_id - ) - match event_message.type: - case EventType.STATUS: - statuses = self.data[event_message_ha_id].status - for event in event_message.data.items: - status_key = StatusKey(event.key) - if status_key in statuses: - statuses[status_key].value = event.value - else: - statuses[status_key] = Status( - key=status_key, - raw_key=status_key.value, - value=event.value, - ) - if ( - status_key == StatusKey.BSH_COMMON_OPERATION_STATE - and event.value == BSH_OPERATION_STATE_PAUSE - and CommandKey.BSH_COMMON_RESUME_PROGRAM - not in ( - commands := self.data[ - event_message_ha_id - ].commands - ) - ): - # All the appliances that can be paused - # should have the resume command available. - commands.add(CommandKey.BSH_COMMON_RESUME_PROGRAM) - for ( - listener, - context, - ) in self._special_listeners.values(): - if ( - EventKey.BSH_COMMON_APPLIANCE_DEPAIRED - not in context - ): - listener() - self._call_event_listener(event_message) - - case EventType.NOTIFY: - settings = self.data[event_message_ha_id].settings - events = self.data[event_message_ha_id].events - for event in event_message.data.items: - event_key = event.key - if event_key in SettingKey.__members__.values(): # type: ignore[comparison-overlap] - setting_key = SettingKey(event_key) - if setting_key in settings: - settings[setting_key].value = event.value - else: - settings[setting_key] = GetSetting( - key=setting_key, - raw_key=setting_key.value, - value=event.value, - ) - else: - event_value = event.value - if event_key in ( - EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, - EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, - ) and isinstance(event_value, str): - await self.update_options( - event_message_ha_id, - event_key, - ProgramKey(event_value), - ) - events[event_key] = event - self._call_event_listener(event_message) - - case EventType.EVENT: - events = self.data[event_message_ha_id].events - for event in event_message.data.items: - events[event.key] = event - self._call_event_listener(event_message) - - case EventType.CONNECTED | EventType.PAIRED: - if self.refreshed_too_often_recently(event_message_ha_id): - continue - - appliance_info = await self.client.get_specific_appliance( - event_message_ha_id - ) - - appliance_data = await self._get_appliance_data( - appliance_info, self.data.get(appliance_info.ha_id) + if event_message_ha_id in self.appliance_coordinators: + if event_message.type == EventType.DEPAIRED: + appliance_coordinator = self.appliance_coordinators.pop( + event_message.ha_id ) - if event_message_ha_id not in self.data: - self.data[event_message_ha_id] = appliance_data - for listener, context in self._special_listeners.values(): - if ( - EventKey.BSH_COMMON_APPLIANCE_DEPAIRED - not in context - ): - listener() - self._call_all_event_listeners_for_appliance( + await appliance_coordinator.async_shutdown() + else: + appliance_coordinator = self.appliance_coordinators[ + event_message.ha_id + ] + if not appliance_coordinator.data.info.connected: + appliance_coordinator.data.info.connected = True + appliance_coordinator.call_all_event_listeners() + + elif event_message.type == EventType.PAIRED: + appliance_coordinator = HomeConnectApplianceCoordinator( + self.hass, + self.config_entry, + self.client, + self.global_listeners, + await self.client.get_specific_appliance( event_message_ha_id - ) - - case EventType.DISCONNECTED: - self.data[event_message_ha_id].info.connected = False - self._call_all_event_listeners_for_appliance( - event_message_ha_id - ) + ), + ) + await appliance_coordinator.async_register_shutdown() + self.appliance_coordinators[event_message.ha_id] = ( + appliance_coordinator + ) - case EventType.DEPAIRED: - device = self.device_registry.async_get_device( - identifiers={(DOMAIN, event_message_ha_id)} - ) - if device: - self.device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=self.config_entry.entry_id, - ) - self.data.pop(event_message_ha_id, None) - for listener, context in self._special_listeners.values(): - assert isinstance(context, tuple) - if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED in context: - listener() + assert appliance_coordinator + await appliance_coordinator.event_listener(event_message) except (EventStreamInterruptedError, HomeConnectRequestError) as error: _LOGGER.debug( @@ -327,58 +184,27 @@ async def _event_listener(self) -> None: # noqa: C901 break @callback - def _call_event_listener(self, event_message: EventMessage) -> None: - """Call listener for event.""" - for event in event_message.data.items: - for listener in self.context_listeners.get( - (event_message.ha_id, event.key), [] - ): - listener() - - @callback - def _call_all_event_listeners_for_appliance(self, ha_id: str) -> None: - for listener, context in self._listeners.values(): - if isinstance(context, tuple) and context[0] == ha_id: - listener() + def async_add_global_listener( + self, + update_callback: CALLBACK_TYPE, + context: tuple[EventKey, ...], + ) -> Callable[[], None]: + """Listen for special data updates. - async def _async_update_data(self) -> dict[str, HomeConnectApplianceData]: - """Fetch data from Home Connect.""" - await self._async_setup() + These listeners will not be called on refresh. + """ - for appliance_data in self.data.values(): - appliance = appliance_data.info - ha_id = appliance.ha_id - while True: - try: - self.data[ha_id] = await self._get_appliance_data( - appliance, self.data.get(ha_id) - ) - except TooManyRequestsError as err: - _LOGGER.debug( - "Rate limit exceeded on initial fetch: %s", - err, - ) - await asyncio_sleep(err.retry_after or API_DEFAULT_RETRY_AFTER) - else: - break + @callback + def remove_listener() -> None: + """Remove update listener.""" + self.global_listeners.pop(remove_listener) - for listener, context in self._special_listeners.values(): - assert isinstance(context, tuple) - if EventKey.BSH_COMMON_APPLIANCE_PAIRED in context: - listener() + self.global_listeners[remove_listener] = (update_callback, context) - return self.data - - async def async_setup(self) -> None: - """Set up the devices.""" - try: - await self._async_setup() - except UpdateFailed as err: - raise ConfigEntryNotReady from err + return remove_listener - async def _async_setup(self) -> None: - """Set up the devices.""" - old_appliances = set(self.data.keys()) + async def setup_appliance_coordinators(self) -> None: + """Set up the coordinators for each appliance.""" try: appliances = await self.client.get_home_appliances() except UnauthorizedError as error: @@ -388,9 +214,7 @@ async def _async_setup(self) -> None: translation_placeholders=get_dict_from_home_connect_error(error), ) from error except HomeConnectError as error: - for appliance_data in self.data.values(): - appliance_data.info.connected = False - raise UpdateFailed( + raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="fetch_api_error", translation_placeholders=get_dict_from_home_connect_error(error), @@ -404,52 +228,237 @@ async def _async_setup(self) -> None: name=appliance.name, model=appliance.vib, ) - if appliance.ha_id not in self.data: - self.data[appliance.ha_id] = HomeConnectApplianceData.empty(appliance) - else: - self.data[appliance.ha_id].info.connected = appliance.connected - old_appliances.remove(appliance.ha_id) - - for ha_id in old_appliances: - self.data.pop(ha_id, None) - device = self.device_registry.async_get_device( - identifiers={(DOMAIN, ha_id)} + new_coordinator = HomeConnectApplianceCoordinator( + self.hass, + self.config_entry, + self.client, + self.global_listeners, + appliance, ) - if device: - self.device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=self.config_entry.entry_id, + await new_coordinator.async_register_shutdown() + self.appliance_coordinators[appliance.ha_id] = new_coordinator + + +class HomeConnectApplianceCoordinator(DataUpdateCoordinator[HomeConnectApplianceData]): + """Class to manage fetching Home Connect appliance data.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: HomeConnectConfigEntry, + client: HomeConnectClient, + global_listeners: dict[ + CALLBACK_TYPE, tuple[CALLBACK_TYPE, tuple[EventKey, ...]] + ], + appliance: HomeAppliance, + ) -> None: + """Initialize.""" + # Don't set config_entry attribute to avoid default behavior. + # HomeConnectApplianceCoordinator doesn't follow the + # config entry lifecycle so we can't use the default behavior. + self._config_entry = config_entry + super().__init__( + hass, + _LOGGER, + config_entry=None, + name=f"{self._config_entry.entry_id}-{appliance.ha_id}", + ) + self.client = client + self.device_registry = dr.async_get(self.hass) + self.global_listeners = global_listeners + self.data = HomeConnectApplianceData.empty(appliance) + self._execution_tracker: list[float] = [] + + def _get_listeners_for_event_key(self, event_key: EventKey) -> list[CALLBACK_TYPE]: + return [ + listener + for listener, context in list(self._listeners.values()) + if context == event_key + ] + + async def event_listener(self, event_message: EventMessage) -> None: + """Match event with listener for event type.""" + + match event_message.type: + case EventType.STATUS: + statuses = self.data.status + for event in event_message.data.items: + status_key = StatusKey(event.key) + if status_key in statuses: + statuses[status_key].value = event.value + else: + statuses[status_key] = Status( + key=status_key, + raw_key=status_key.value, + value=event.value, + ) + if ( + status_key == StatusKey.BSH_COMMON_OPERATION_STATE + and event.value == BSH_OPERATION_STATE_PAUSE + and CommandKey.BSH_COMMON_RESUME_PROGRAM + not in (commands := self.data.commands) + ): + # All the appliances that can be paused + # should have the resume command available. + commands.add(CommandKey.BSH_COMMON_RESUME_PROGRAM) + for ( + listener, + context, + ) in self.global_listeners.values(): + if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED not in context: + listener() + self._call_event_listener(event_message) + + case EventType.NOTIFY: + settings = self.data.settings + events = self.data.events + for event in event_message.data.items: + event_key = event.key + if event_key in SettingKey.__members__.values(): # type: ignore[comparison-overlap] + setting_key = SettingKey(event_key) + if setting_key in settings: + settings[setting_key].value = event.value + else: + settings[setting_key] = GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=event.value, + ) + else: + event_value = event.value + if event_key in ( + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ) and isinstance(event_value, str): + await self.update_options( + event_key, + ProgramKey(event_value), + ) + events[event_key] = event + self._call_event_listener(event_message) + + case EventType.EVENT: + events = self.data.events + for event in event_message.data.items: + events[event.key] = event + self._call_event_listener(event_message) + + case EventType.CONNECTED | EventType.PAIRED: + if self.refreshed_too_often_recently(): + return + + await self.async_refresh() + for ( + listener, + context, + ) in self.global_listeners.values(): + if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED not in context: + listener() + self.call_all_event_listeners() + + case EventType.DISCONNECTED: + self.data.info.connected = False + self.call_all_event_listeners() + + case EventType.DEPAIRED: + device = self.device_registry.async_get_device( + identifiers={(DOMAIN, self.data.info.ha_id)} ) + if device: + self.device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self._config_entry.entry_id, + ) + for ( + listener, + context, + ) in self.global_listeners.values(): + assert isinstance(context, tuple) + if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED in context: + listener() + + @callback + def _call_event_listener(self, event_message: EventMessage) -> None: + """Call listener for event.""" + for event in event_message.data.items: + for listener in self._get_listeners_for_event_key(event.key): + listener() + + @callback + def call_all_event_listeners(self) -> None: + """Call all listeners.""" + for listener, _ in self._listeners.values(): + listener() + + async def _async_update_data(self) -> HomeConnectApplianceData: + """Fetch data from Home Connect.""" + while True: + try: + try: + self.data.info.connected = ( + await self.client.get_specific_appliance(self.data.info.ha_id) + ).connected + except HomeConnectError: + self.data.info.connected = False + raise + + await self.get_appliance_data() + except TooManyRequestsError as err: + delay = err.retry_after or API_DEFAULT_RETRY_AFTER + _LOGGER.warning( + "Rate limit exceeded, retrying in %s seconds: %s", + delay, + err, + ) + await asyncio_sleep(delay) + except UnauthorizedError as error: + # Reauth flow need to be started explicitly as + # we don't use the default config entry coordinator. + self._config_entry.async_start_reauth(self.hass) + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + translation_placeholders=get_dict_from_home_connect_error(error), + ) from error + except HomeConnectError as error: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="fetch_api_error", + translation_placeholders=get_dict_from_home_connect_error(error), + ) from error + else: + break - # Trigger to delete the possible depaired device entities - # from known_entities variable at common.py - for listener, context in self._special_listeners.values(): + for ( + listener, + context, + ) in self.global_listeners.values(): assert isinstance(context, tuple) - if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED in context: + if EventKey.BSH_COMMON_APPLIANCE_PAIRED in context: listener() - async def _get_appliance_data( - self, - appliance: HomeAppliance, - appliance_data_to_update: HomeConnectApplianceData | None = None, - ) -> HomeConnectApplianceData: + return self.data + + async def get_appliance_data(self) -> None: """Get appliance data.""" + appliance = self.data.info self.device_registry.async_get_or_create( - config_entry_id=self.config_entry.entry_id, + config_entry_id=self._config_entry.entry_id, identifiers={(DOMAIN, appliance.ha_id)}, manufacturer=appliance.brand, name=appliance.name, model=appliance.vib, ) if not appliance.connected: - _LOGGER.debug( - "Appliance %s is not connected, skipping data fetch", - appliance.ha_id, + self.data.update(HomeConnectApplianceData.empty(appliance)) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="appliance_disconnected", + translation_placeholders={ + "appliance_name": appliance.name, + "ha_id": appliance.ha_id, + }, ) - if appliance_data_to_update: - appliance_data_to_update.info.connected = False - return appliance_data_to_update - return HomeConnectApplianceData.empty(appliance) try: settings = { setting.key: setting @@ -521,9 +530,7 @@ async def _get_appliance_data( current_program_key = program.key program_options = program.options if current_program_key: - options = await self.get_options_definitions( - appliance.ha_id, current_program_key - ) + options = await self.get_options_definitions(current_program_key) for option in program_options or []: option_event_key = EventKey(option.key) events[option_event_key] = Event( @@ -550,23 +557,20 @@ async def _get_appliance_data( except HomeConnectError: commands = set() - appliance_data = HomeConnectApplianceData( - commands=commands, - events=events, - info=appliance, - options=options, - programs=programs, - settings=settings, - status=status, + self.data.update( + HomeConnectApplianceData( + commands=commands, + events=events, + info=appliance, + options=options, + programs=programs, + settings=settings, + status=status, + ) ) - if appliance_data_to_update: - appliance_data_to_update.update(appliance_data) - appliance_data = appliance_data_to_update - - return appliance_data async def get_options_definitions( - self, ha_id: str, program_key: ProgramKey + self, program_key: ProgramKey ) -> dict[OptionKey, ProgramDefinitionOption]: """Get options with constraints for appliance.""" if program_key is ProgramKey.UNKNOWN: @@ -576,7 +580,7 @@ async def get_options_definitions( option.key: option for option in ( await self.client.get_available_program( - ha_id, program_key=program_key + self.data.info.ha_id, program_key=program_key ) ).options or [] @@ -586,20 +590,20 @@ async def get_options_definitions( except HomeConnectError as error: _LOGGER.debug( "Error fetching options for %s: %s", - ha_id, + self.data.info.ha_id, error, ) return {} async def update_options( - self, ha_id: str, event_key: EventKey, program_key: ProgramKey + self, event_key: EventKey, program_key: ProgramKey ) -> None: """Update options for appliance.""" - options = self.data[ha_id].options - events = self.data[ha_id].events + options = self.data.options + events = self.data.events options_to_notify = options.copy() options.clear() - options.update(await self.get_options_definitions(ha_id, program_key)) + options.update(await self.get_options_definitions(program_key)) for option in options.values(): option_value = option.constraints.default if option.constraints else None @@ -617,21 +621,18 @@ async def update_options( ) options_to_notify.update(options) for option_key in options_to_notify: - for listener in self.context_listeners.get( - (ha_id, EventKey(option_key)), - [], - ): + for listener in self._get_listeners_for_event_key(EventKey(option_key)): listener() - def refreshed_too_often_recently(self, appliance_ha_id: str) -> bool: + def refreshed_too_often_recently(self) -> bool: """Check if the appliance data hasn't been refreshed too often recently.""" now = self.hass.loop.time() - execution_tracker = self._execution_tracker[appliance_ha_id] + execution_tracker = self._execution_tracker initial_len = len(execution_tracker) - execution_tracker = self._execution_tracker[appliance_ha_id] = [ + execution_tracker = self._execution_tracker = [ timestamp for timestamp in execution_tracker if now - timestamp < MAX_EXECUTIONS_TIME_WINDOW @@ -647,7 +648,7 @@ def refreshed_too_often_recently(self, appliance_ha_id: str) -> bool: "and they will be enabled again whenever the connection stabilizes. " "Consider trying to unplug the appliance " "for a while to perform a soft reset", - self.data[appliance_ha_id].info.name, + self.data.info.name, MAX_EXECUTIONS, MAX_EXECUTIONS_TIME_WINDOW // 60, ) @@ -656,7 +657,7 @@ def refreshed_too_often_recently(self, appliance_ha_id: str) -> bool: _LOGGER.info( 'Connected/paired events from the appliance "%s" have stabilized,' " updates have been re-enabled", - self.data[appliance_ha_id].info.name, + self.data.info.name, ) return False diff --git a/homeassistant/components/home_connect/diagnostics.py b/homeassistant/components/home_connect/diagnostics.py index f5f4999fa2e653..08558fcd23264d 100644 --- a/homeassistant/components/home_connect/diagnostics.py +++ b/homeassistant/components/home_connect/diagnostics.py @@ -47,8 +47,10 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" return { - appliance.info.ha_id: await _generate_appliance_diagnostics(appliance) - for appliance in entry.runtime_data.data.values() + appliance_coordinator.data.info.ha_id: await _generate_appliance_diagnostics( + appliance_coordinator.data + ) + for appliance_coordinator in entry.runtime_data.appliance_coordinators.values() } @@ -59,4 +61,6 @@ async def async_get_device_diagnostics( ha_id = next( (identifier[1] for identifier in device.identifiers if identifier[0] == DOMAIN), ) - return await _generate_appliance_diagnostics(entry.runtime_data.data[ha_id]) + return await _generate_appliance_diagnostics( + entry.runtime_data.appliance_coordinators[ha_id].data + ) diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index 4c3e9702cd0bd7..c4a45e5603b5d8 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -22,34 +22,34 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import API_DEFAULT_RETRY_AFTER, DOMAIN -from .coordinator import HomeConnectApplianceData, HomeConnectCoordinator +from .coordinator import HomeConnectApplianceCoordinator from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) -class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]): +class HomeConnectEntity(CoordinatorEntity[HomeConnectApplianceCoordinator]): """Generic Home Connect entity (base class).""" _attr_has_entity_name = True def __init__( self, - coordinator: HomeConnectCoordinator, - appliance: HomeConnectApplianceData, + appliance_coordinator: HomeConnectApplianceCoordinator, desc: EntityDescription, context_override: Any | None = None, ) -> None: """Initialize the entity.""" - context = (appliance.info.ha_id, EventKey(desc.key)) + appliance_ha_id = appliance_coordinator.data.info.ha_id + context = EventKey(desc.key) if context_override is not None: context = context_override - super().__init__(coordinator, context) - self.appliance = appliance + super().__init__(appliance_coordinator, context) + self.appliance = appliance_coordinator.data self.entity_description = desc - self._attr_unique_id = f"{appliance.info.ha_id}-{desc.key}" + self._attr_unique_id = f"{appliance_ha_id}-{desc.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, appliance.info.ha_id)}, + identifiers={(DOMAIN, appliance_ha_id)}, ) self.update_native_value() @@ -79,49 +79,21 @@ def available(self) -> bool: """ return self.appliance.info.connected and self._attr_available - -class HomeConnectOptionEntity(HomeConnectEntity): - """Class for entities that represents program options.""" - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return super().available and self.bsh_key in self.appliance.options - - @property - def option_value(self) -> str | int | float | bool | None: - """Return the state of the entity.""" - if event := self.appliance.events.get(EventKey(self.bsh_key)): - return event.value - return None - - async def async_set_option(self, value: str | float | bool) -> None: + async def async_set_option_with_key( + self, option_key: OptionKey, value: Any + ) -> None: """Set an option for the entity.""" try: # We try to set the active program option first, # if it fails we try to set the selected program option with contextlib.suppress(ActiveProgramNotSetError): await self.coordinator.client.set_active_program_option( - self.appliance.info.ha_id, - option_key=self.bsh_key, - value=value, - ) - _LOGGER.debug( - "Updated %s for the active program, new state: %s", - self.entity_id, - self.state, + self.appliance.info.ha_id, option_key=option_key, value=value ) return await self.coordinator.client.set_selected_program_option( - self.appliance.info.ha_id, - option_key=self.bsh_key, - value=value, - ) - _LOGGER.debug( - "Updated %s for the selected program, new state: %s", - self.entity_id, - self.state, + self.appliance.info.ha_id, option_key=option_key, value=value ) except HomeConnectError as err: raise HomeAssistantError( @@ -130,6 +102,26 @@ async def async_set_option(self, value: str | float | bool) -> None: translation_placeholders=get_dict_from_home_connect_error(err), ) from err + +class HomeConnectOptionEntity(HomeConnectEntity): + """Class for entities that represents program options.""" + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.bsh_key in self.appliance.options + + @property + def option_value(self) -> str | int | float | bool | None: + """Return the state of the entity.""" + if event := self.appliance.events.get(EventKey(self.bsh_key)): + return event.value + return None + + async def async_set_option(self, value: Any) -> None: + """Set an option for the entity.""" + await super().async_set_option_with_key(self.bsh_key, value) + @property def bsh_key(self) -> OptionKey: """Return the BSH key.""" diff --git a/homeassistant/components/home_connect/fan.py b/homeassistant/components/home_connect/fan.py new file mode 100644 index 00000000000000..5188fc34daf352 --- /dev/null +++ b/homeassistant/components/home_connect/fan.py @@ -0,0 +1,203 @@ +"""Provides fan entities for Home Connect.""" + +import logging +from typing import cast + +from aiohomeconnect.model import EventKey, OptionKey + +from homeassistant.components.fan import ( + FanEntity, + FanEntityDescription, + FanEntityFeature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .common import setup_home_connect_entry +from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry +from .entity import HomeConnectEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 1 + +FAN_SPEED_MODE_OPTIONS = { + "auto": "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic", + "manual": "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual", +} +FAN_SPEED_MODE_OPTIONS_INVERTED = {v: k for k, v in FAN_SPEED_MODE_OPTIONS.items()} + + +AIR_CONDITIONER_ENTITY_DESCRIPTION = FanEntityDescription( + key="air_conditioner", + translation_key="air_conditioner", + name=None, +) + + +def _get_entities_for_appliance( + appliance_coordinator: HomeConnectApplianceCoordinator, +) -> list[HomeConnectEntity]: + """Get a list of entities.""" + return ( + [HomeConnectAirConditioningFanEntity(appliance_coordinator)] + if appliance_coordinator.data.options + and any( + option in appliance_coordinator.data.options + for option in ( + OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE, + OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE, + ) + ) + else [] + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HomeConnectConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Home Connect fan entities.""" + setup_home_connect_entry( + hass, + entry, + _get_entities_for_appliance, + async_add_entities, + lambda appliance_coordinator, _: _get_entities_for_appliance( + appliance_coordinator + ), + ) + + +class HomeConnectAirConditioningFanEntity(HomeConnectEntity, FanEntity): + """Representation of a Home Connect fan entity.""" + + def __init__( + self, + coordinator: HomeConnectApplianceCoordinator, + ) -> None: + """Initialize the entity.""" + self._attr_preset_modes = list(FAN_SPEED_MODE_OPTIONS.keys()) + self._original_speed_modes_keys = set(FAN_SPEED_MODE_OPTIONS_INVERTED) + super().__init__( + coordinator, + AIR_CONDITIONER_ENTITY_DESCRIPTION, + context_override=( + EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE + ), + ) + self.update_preset_mode() + + @callback + def _handle_coordinator_update_preset_mode(self) -> None: + """Handle updated data from the coordinator.""" + self.update_preset_mode() + self.async_write_ha_state() + _LOGGER.debug( + "Updated %s (fan mode), new state: %s", self.entity_id, self.preset_mode + ) + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + 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, + ) + ) + + def update_native_value(self) -> None: + """Set the speed percentage and speed mode values.""" + option_value = None + option_key = OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE + if event := self.appliance.events.get(EventKey(option_key)): + option_value = event.value + self._attr_percentage = ( + cast(int, option_value) if option_value is not None else None + ) + + @property + def supported_features(self) -> FanEntityFeature: + """Return the supported features for this fan entity.""" + features = FanEntityFeature(0) + if ( + OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE + in self.appliance.options + ): + features |= FanEntityFeature.SET_SPEED + if ( + OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE + in self.appliance.options + ): + features |= FanEntityFeature.PRESET_MODE + return features + + def update_preset_mode(self) -> None: + """Set the preset mode value.""" + option_value = None + option_key = OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE + if event := self.appliance.events.get(EventKey(option_key)): + option_value = event.value + self._attr_preset_mode = ( + FAN_SPEED_MODE_OPTIONS_INVERTED.get(cast(str, option_value)) + if option_value is not None + else None + ) + if ( + ( + option_definition := self.appliance.options.get( + OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE + ) + ) + and (option_constraints := option_definition.constraints) + and option_constraints.allowed_values + and ( + allowed_values_without_none := { + value + for value in option_constraints.allowed_values + if value is not None + } + ) + and self._original_speed_modes_keys != allowed_values_without_none + ): + self._original_speed_modes_keys = allowed_values_without_none + self._attr_preset_modes = [ + key + for key, value in FAN_SPEED_MODE_OPTIONS.items() + if value in self._original_speed_modes_keys + ] + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed of the fan, as a percentage.""" + await super().async_set_option_with_key( + OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE, + percentage, + ) + _LOGGER.debug( + "Updated %s's speed percentage option, new state: %s", + self.entity_id, + percentage, + ) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new target fan mode.""" + await super().async_set_option_with_key( + OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE, + FAN_SPEED_MODE_OPTIONS[preset_mode], + ) + _LOGGER.debug( + "Updated %s's speed mode option, new state: %s", self.entity_id, self.state + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and any( + option in self.appliance.options + for option in ( + OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE, + OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE, + ) + ) diff --git a/homeassistant/components/home_connect/icons.json b/homeassistant/components/home_connect/icons.json index 3aac3792925d76..6d411c69cdbc05 100644 --- a/homeassistant/components/home_connect/icons.json +++ b/homeassistant/components/home_connect/icons.json @@ -245,25 +245,10 @@ "change_setting": { "service": "mdi:cog" }, - "pause_program": { - "service": "mdi:pause" - }, - "resume_program": { - "service": "mdi:play-pause" - }, - "select_program": { - "service": "mdi:form-select" - }, - "set_option_active": { - "service": "mdi:gesture-tap" - }, - "set_option_selected": { - "service": "mdi:gesture-tap" - }, "set_program_and_options": { "service": "mdi:form-select" }, - "start_program": { + "start_selected_program": { "service": "mdi:play" } } diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py index 2cf1ecab347618..b7ae351f93735d 100644 --- a/homeassistant/components/home_connect/light.py +++ b/homeassistant/components/home_connect/light.py @@ -22,11 +22,7 @@ from .common import setup_home_connect_entry from .const import BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, DOMAIN -from .coordinator import ( - HomeConnectApplianceData, - HomeConnectConfigEntry, - HomeConnectCoordinator, -) +from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry from .entity import HomeConnectEntity from .utils import get_dict_from_home_connect_error @@ -78,14 +74,13 @@ class HomeConnectLightEntityDescription(LightEntityDescription): def _get_entities_for_appliance( - entry: HomeConnectConfigEntry, - appliance: HomeConnectApplianceData, + appliance_coordinator: HomeConnectApplianceCoordinator, ) -> list[HomeConnectEntity]: """Get a list of entities.""" return [ - HomeConnectLight(entry.runtime_data, appliance, description) + HomeConnectLight(appliance_coordinator, description) for description in LIGHTS - if description.key in appliance.settings + if description.key in appliance_coordinator.data.settings ] @@ -110,8 +105,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): def __init__( self, - coordinator: HomeConnectCoordinator, - appliance: HomeConnectApplianceData, + appliance_coordinator: HomeConnectApplianceCoordinator, desc: HomeConnectLightEntityDescription, ) -> None: """Initialize the entity.""" @@ -119,7 +113,7 @@ def __init__( def get_setting_key_if_setting_exists( setting_key: SettingKey | None, ) -> SettingKey | None: - if setting_key and setting_key in appliance.settings: + if setting_key and setting_key in appliance_coordinator.data.settings: return setting_key return None @@ -134,7 +128,7 @@ def get_setting_key_if_setting_exists( ) self._brightness_scale = desc.brightness_scale - super().__init__(coordinator, appliance, desc) + super().__init__(appliance_coordinator, desc) match (self._brightness_key, self._custom_color_key): case (None, None): @@ -287,10 +281,7 @@ async def async_added_to_hass(self) -> None: self.async_on_remove( self.coordinator.async_add_listener( self._handle_coordinator_update, - ( - self.appliance.info.ha_id, - EventKey(key), - ), + EventKey(key), ) ) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 96a6a2d83b44c4..d5955e07e22358 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.28.0"], + "requirements": ["aiohomeconnect==0.33.0"], "zeroconf": ["_homeconnect._tcp.local."] } diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index 2d9c47e871b079..2a366574ec3036 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -19,7 +19,7 @@ from .common import setup_home_connect_entry, should_add_option_entity from .const import DOMAIN, UNIT_MAP -from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry +from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry from .entity import HomeConnectEntity, HomeConnectOptionEntity, constraint_fetcher from .utils import get_dict_from_home_connect_error @@ -123,28 +123,26 @@ def _get_entities_for_appliance( - entry: HomeConnectConfigEntry, - appliance: HomeConnectApplianceData, + appliance_coordinator: HomeConnectApplianceCoordinator, ) -> list[HomeConnectEntity]: """Get a list of entities.""" return [ - HomeConnectNumberEntity(entry.runtime_data, appliance, description) + HomeConnectNumberEntity(appliance_coordinator, description) for description in NUMBERS - if description.key in appliance.settings + if description.key in appliance_coordinator.data.settings ] def _get_option_entities_for_appliance( - entry: HomeConnectConfigEntry, - appliance: HomeConnectApplianceData, + appliance_coordinator: HomeConnectApplianceCoordinator, entity_registry: er.EntityRegistry, -) -> list[HomeConnectOptionEntity]: +) -> list[HomeConnectEntity]: """Get a list of currently available option entities.""" return [ - HomeConnectOptionNumberEntity(entry.runtime_data, appliance, description) + HomeConnectOptionNumberEntity(appliance_coordinator, description) for description in NUMBER_OPTIONS if should_add_option_entity( - description, appliance, entity_registry, Platform.NUMBER + description, appliance_coordinator.data, entity_registry, Platform.NUMBER ) ] diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index 374d317032db63..eab1a0a4b1730e 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -41,11 +41,7 @@ VENTING_LEVEL_OPTIONS, WARMING_LEVEL_OPTIONS, ) -from .coordinator import ( - HomeConnectApplianceData, - HomeConnectConfigEntry, - HomeConnectCoordinator, -) +from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry from .entity import HomeConnectEntity, HomeConnectOptionEntity, constraint_fetcher from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error @@ -336,37 +332,37 @@ class HomeConnectSelectEntityDescription(SelectEntityDescription): def _get_entities_for_appliance( - entry: HomeConnectConfigEntry, - appliance: HomeConnectApplianceData, + appliance_coordinator: HomeConnectApplianceCoordinator, ) -> list[HomeConnectEntity]: """Get a list of entities.""" return [ *( [ - HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc) + HomeConnectProgramSelectEntity(appliance_coordinator, desc) for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS ] - if appliance.programs + if appliance_coordinator.data.programs else [] ), *[ - HomeConnectSelectEntity(entry.runtime_data, appliance, desc) + HomeConnectSelectEntity(appliance_coordinator, desc) for desc in SELECT_ENTITY_DESCRIPTIONS - if desc.key in appliance.settings + if desc.key in appliance_coordinator.data.settings ], ] def _get_option_entities_for_appliance( - entry: HomeConnectConfigEntry, - appliance: HomeConnectApplianceData, + appliance_coordinator: HomeConnectApplianceCoordinator, entity_registry: er.EntityRegistry, -) -> list[HomeConnectOptionEntity]: +) -> list[HomeConnectEntity]: """Get a list of entities.""" return [ - HomeConnectSelectOptionEntity(entry.runtime_data, appliance, desc) + HomeConnectSelectOptionEntity(appliance_coordinator, desc) for desc in PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS - if should_add_option_entity(desc, appliance, entity_registry, Platform.SELECT) + if should_add_option_entity( + desc, appliance_coordinator.data, entity_registry, Platform.SELECT + ) ] @@ -392,14 +388,12 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): def __init__( self, - coordinator: HomeConnectCoordinator, - appliance: HomeConnectApplianceData, + appliance_coordinator: HomeConnectApplianceCoordinator, desc: HomeConnectProgramSelectEntityDescription, ) -> None: """Initialize the entity.""" super().__init__( - coordinator, - appliance, + appliance_coordinator, desc, ) self.set_options() @@ -409,7 +403,7 @@ def set_options(self) -> None: self._attr_options = [ PROGRAMS_TRANSLATION_KEYS_MAP[program.key] for program in self.appliance.programs - if program.key != ProgramKey.UNKNOWN + if program.key in PROGRAMS_TRANSLATION_KEYS_MAP and ( program.constraints is None or program.constraints.execution @@ -429,7 +423,7 @@ async def async_added_to_hass(self) -> None: self.async_on_remove( self.coordinator.async_add_listener( self.refresh_options, - (self.appliance.info.ha_id, EventKey.BSH_COMMON_APPLIANCE_CONNECTED), + EventKey.BSH_COMMON_APPLIANCE_CONNECTED, ) ) @@ -470,15 +464,13 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity): def __init__( self, - coordinator: HomeConnectCoordinator, - appliance: HomeConnectApplianceData, + appliance_coordinator: HomeConnectApplianceCoordinator, desc: HomeConnectSelectEntityDescription, ) -> None: """Initialize the entity.""" self._original_option_keys = set(desc.values_translation_key) super().__init__( - coordinator, - appliance, + appliance_coordinator, desc, ) @@ -547,15 +539,13 @@ class HomeConnectSelectOptionEntity(HomeConnectOptionEntity, SelectEntity): def __init__( self, - coordinator: HomeConnectCoordinator, - appliance: HomeConnectApplianceData, + appliance_coordinator: HomeConnectApplianceCoordinator, desc: HomeConnectSelectEntityDescription, ) -> None: """Initialize the entity.""" self._original_option_keys = set(desc.values_translation_key) super().__init__( - coordinator, - appliance, + appliance_coordinator, desc, ) diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 1075e6d08009d8..810d7ad356d102 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -26,7 +26,7 @@ BSH_OPERATION_STATE_RUN, UNIT_MAP, ) -from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry +from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry from .entity import HomeConnectEntity, constraint_fetcher _LOGGER = logging.getLogger(__name__) @@ -508,26 +508,26 @@ class HomeConnectSensorEntityDescription( def _get_entities_for_appliance( - entry: HomeConnectConfigEntry, - appliance: HomeConnectApplianceData, + appliance_coordinator: HomeConnectApplianceCoordinator, ) -> list[HomeConnectEntity]: """Get a list of entities.""" return [ *[ - HomeConnectEventSensor(entry.runtime_data, appliance, description) + HomeConnectEventSensor(appliance_coordinator, description) for description in EVENT_SENSORS if description.appliance_types - and appliance.info.type in description.appliance_types + and appliance_coordinator.data.info.type in description.appliance_types ], *[ - HomeConnectProgramSensor(entry.runtime_data, appliance, desc) + HomeConnectProgramSensor(appliance_coordinator, desc) for desc in BSH_PROGRAM_SENSORS - if desc.appliance_types and appliance.info.type in desc.appliance_types + if desc.appliance_types + and appliance_coordinator.data.info.type in desc.appliance_types ], *[ - HomeConnectSensor(entry.runtime_data, appliance, description) + HomeConnectSensor(appliance_coordinator, description) for description in SENSORS - if description.key in appliance.status + if description.key in appliance_coordinator.data.status ], ] @@ -607,7 +607,7 @@ async def async_added_to_hass(self) -> None: self.async_on_remove( self.coordinator.async_add_listener( self._handle_operation_state_event, - (self.appliance.info.ha_id, EventKey.BSH_COMMON_STATUS_OPERATION_STATE), + EventKey.BSH_COMMON_STATUS_OPERATION_STATE, ) ) diff --git a/homeassistant/components/home_connect/services.py b/homeassistant/components/home_connect/services.py index ca6eca4d9192dc..bb9783be62b03c 100644 --- a/homeassistant/components/home_connect/services.py +++ b/homeassistant/components/home_connect/services.py @@ -13,7 +13,7 @@ ProgramKey, SettingKey, ) -from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.error import HomeConnectError, NoProgramActiveError import voluptuous as vol from homeassistant.const import ATTR_DEVICE_ID @@ -32,6 +32,7 @@ PROGRAM_ENUM_OPTIONS, SERVICE_SET_PROGRAM_AND_OPTIONS, SERVICE_SETTING, + SERVICE_START_SELECTED_PROGRAM, TRANSLATION_KEYS_PROGRAMS_MAP, ) from .coordinator import HomeConnectConfigEntry @@ -46,10 +47,12 @@ value, ) for key, value in { - OptionKey.BSH_COMMON_DURATION: int, - OptionKey.BSH_COMMON_START_IN_RELATIVE: int, - OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: int, - OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: int, + OptionKey.BSH_COMMON_DURATION: vol.All(int, vol.Range(min=0)), + OptionKey.BSH_COMMON_START_IN_RELATIVE: vol.All(int, vol.Range(min=0)), + OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: vol.All(int, vol.Range(min=0)), + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: vol.All( + int, vol.Range(min=0) + ), OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES: bool, OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE: bool, OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY: bool, @@ -60,7 +63,10 @@ OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS: bool, OptionKey.DISHCARE_DISHWASHER_ECO_DRY: bool, OptionKey.DISHCARE_DISHWASHER_ZEOLITE_DRY: bool, - OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: int, + OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE: vol.All( + int, vol.Range(min=1, max=100) + ), + OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: vol.All(int, vol.Range(min=0)), OptionKey.COOKING_OVEN_FAST_PRE_HEAT: bool, OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE: bool, OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE: bool, @@ -119,7 +125,23 @@ def _require_program_or_at_least_one_option(data: dict) -> dict: _require_program_or_at_least_one_option, ) -SERVICE_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str}) +SERVICE_START_SELECTED_PROGRAM_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + } + ).extend( + { + vol.Optional(translation_key): schema + for translation_key, (key, schema) in PROGRAM_OPTIONS.items() + if key + in ( + OptionKey.BSH_COMMON_START_IN_RELATIVE, + OptionKey.BSH_COMMON_FINISH_IN_RELATIVE, + ) + } + ) +) async def _get_client_and_ha_id( @@ -257,6 +279,50 @@ async def async_service_set_program_and_options(call: ServiceCall) -> None: ) from err +async def async_service_start_selected_program(call: ServiceCall) -> None: + """Service to start a program that is already selected.""" + data = dict(call.data) + client, ha_id = await _get_client_and_ha_id(call.hass, data.pop(ATTR_DEVICE_ID)) + try: + try: + program_obj = await client.get_active_program(ha_id) + except NoProgramActiveError: + program_obj = await client.get_selected_program(ha_id) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="fetch_program_error", + translation_placeholders=get_dict_from_home_connect_error(err), + ) from err + if not program_obj.key: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="no_program_to_start", + ) + + program = program_obj.key + options_dict = {option.key: option for option in program_obj.options or []} + for option, value in data.items(): + option_key = PROGRAM_OPTIONS[option][0] + options_dict[option_key] = Option(option_key, value) + + try: + await client.start_program( + ha_id, + program_key=program, + options=list(options_dict.values()) if options_dict else None, + ) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="start_program", + translation_placeholders={ + "program": program, + **get_dict_from_home_connect_error(err), + }, + ) from err + + @callback def async_setup_services(hass: HomeAssistant) -> None: """Register custom actions.""" @@ -270,3 +336,9 @@ def async_setup_services(hass: HomeAssistant) -> None: async_service_set_program_and_options, schema=SERVICE_PROGRAM_AND_OPTIONS_SCHEMA, ) + hass.services.async_register( + DOMAIN, + SERVICE_START_SELECTED_PROGRAM, + async_service_start_selected_program, + schema=SERVICE_START_SELECTED_PROGRAM_SCHEMA, + ) diff --git a/homeassistant/components/home_connect/services.yaml b/homeassistant/components/home_connect/services.yaml index ef86ac066b5ed3..2bec0dc6cf58d1 100644 --- a/homeassistant/components/home_connect/services.yaml +++ b/homeassistant/components/home_connect/services.yaml @@ -119,6 +119,10 @@ set_program_and_options: - cooking_common_program_hood_automatic - cooking_common_program_hood_venting - cooking_common_program_hood_delayed_shut_off + - cooking_oven_program_heating_mode_3_d_heating + - cooking_oven_program_heating_mode_air_fry + - cooking_oven_program_heating_mode_grill_large_area + - cooking_oven_program_heating_mode_grill_small_area - cooking_oven_program_heating_mode_pre_heating - cooking_oven_program_heating_mode_hot_air - cooking_oven_program_heating_mode_hot_air_eco @@ -127,6 +131,7 @@ set_program_and_options: - cooking_oven_program_heating_mode_top_bottom_heating - cooking_oven_program_heating_mode_top_bottom_heating_eco - cooking_oven_program_heating_mode_bottom_heating + - cooking_oven_program_heating_mode_bread_baking - cooking_oven_program_heating_mode_pizza_setting - cooking_oven_program_heating_mode_slow_cook - cooking_oven_program_heating_mode_intensive_heat @@ -135,6 +140,7 @@ set_program_and_options: - cooking_oven_program_heating_mode_frozen_heatup_special - cooking_oven_program_heating_mode_desiccation - cooking_oven_program_heating_mode_defrost + - cooking_oven_program_heating_mode_dough_proving - cooking_oven_program_heating_mode_proof - cooking_oven_program_heating_mode_hot_air_30_steam - cooking_oven_program_heating_mode_hot_air_60_steam @@ -678,3 +684,29 @@ change_setting: required: true selector: object: + +start_selected_program: + fields: + device_id: + required: true + selector: + device: + integration: home_connect + b_s_h_common_option_finish_in_relative: + example: 3600 + required: false + selector: + number: + min: 0 + step: 1 + mode: box + unit_of_measurement: s + b_s_h_common_option_start_in_relative: + example: 3600 + required: false + selector: + number: + min: 0 + step: 1 + mode: box + unit_of_measurement: s diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 6373ccd85f95cf..b49476407dff10 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -119,6 +119,35 @@ "name": "Stop program" } }, + "climate": { + "air_conditioner": { + "state_attributes": { + "fan_mode": { + "state": { + "auto": "[%key:common::state::auto%]", + "manual": "[%key:common::state::manual%]" + } + }, + "preset_mode": { + "state": { + "active_clean": "Active clean" + } + } + } + } + }, + "fan": { + "air_conditioner": { + "state_attributes": { + "preset_mode": { + "state": { + "auto": "[%key:common::state::auto%]", + "manual": "[%key:common::state::manual%]" + } + } + } + } + }, "light": { "ambient_light": { "name": "Ambient light" @@ -231,10 +260,16 @@ "cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]", "cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]", "cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]", + "cooking_oven_program_heating_mode_3_d_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_heating%]", + "cooking_oven_program_heating_mode_air_fry": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_air_fry%]", "cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]", + "cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]", "cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_defrost%]", "cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_desiccation%]", + "cooking_oven_program_heating_mode_dough_proving": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_dough_proving%]", "cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_frozen_heatup_special%]", + "cooking_oven_program_heating_mode_grill_large_area": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_grill_large_area%]", + "cooking_oven_program_heating_mode_grill_small_area": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_grill_small_area%]", "cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air%]", "cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_100_steam%]", "cooking_oven_program_heating_mode_hot_air_30_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_30_steam%]", @@ -585,10 +620,16 @@ "cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]", "cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]", "cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]", + "cooking_oven_program_heating_mode_3_d_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_heating%]", + "cooking_oven_program_heating_mode_air_fry": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_air_fry%]", "cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]", + "cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]", "cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_defrost%]", "cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_desiccation%]", + "cooking_oven_program_heating_mode_dough_proving": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_dough_proving%]", "cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_frozen_heatup_special%]", + "cooking_oven_program_heating_mode_grill_large_area": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_grill_large_area%]", + "cooking_oven_program_heating_mode_grill_small_area": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_grill_small_area%]", "cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air%]", "cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_100_steam%]", "cooking_oven_program_heating_mode_hot_air_30_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_30_steam%]", @@ -1290,6 +1331,9 @@ } }, "exceptions": { + "appliance_disconnected": { + "message": "Appliance {appliance_name} ({ha_id}) is disconnected" + }, "appliance_not_found": { "message": "Appliance for device ID {device_id} not found" }, @@ -1308,6 +1352,12 @@ "fetch_api_error": { "message": "Error obtaining data from the API: {error}" }, + "fetch_program_error": { + "message": "Error obtaining the selected or active program: {error}" + }, + "no_program_to_start": { + "message": "No program to start" + }, "oauth2_implementation_unavailable": { "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" }, @@ -1579,10 +1629,16 @@ "cooking_common_program_hood_automatic": "Automatic", "cooking_common_program_hood_delayed_shut_off": "Delayed shut off", "cooking_common_program_hood_venting": "Venting", + "cooking_oven_program_heating_mode_3_d_heating": "3D heating", + "cooking_oven_program_heating_mode_air_fry": "Air fry", "cooking_oven_program_heating_mode_bottom_heating": "Bottom heating", + "cooking_oven_program_heating_mode_bread_baking": "Bread baking", "cooking_oven_program_heating_mode_defrost": "Defrost", "cooking_oven_program_heating_mode_desiccation": "Desiccation", + "cooking_oven_program_heating_mode_dough_proving": "Dough proving", "cooking_oven_program_heating_mode_frozen_heatup_special": "Special heat-up for frozen products", + "cooking_oven_program_heating_mode_grill_large_area": "Grill (large area)", + "cooking_oven_program_heating_mode_grill_small_area": "Grill (small area)", "cooking_oven_program_heating_mode_hot_air": "Hot air", "cooking_oven_program_heating_mode_hot_air_100_steam": "Hot air + 100 RH", "cooking_oven_program_heating_mode_hot_air_30_steam": "Hot air + 30 RH", @@ -2040,6 +2096,24 @@ "name": "Washer options" } } + }, + "start_selected_program": { + "description": "Starts the already selected program. You can update start-only options to start the program with them or modify them on a program that is already active with a delayed start.", + "fields": { + "b_s_h_common_option_finish_in_relative": { + "description": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_finish_in_relative::description%]", + "name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_finish_in_relative::name%]" + }, + "b_s_h_common_option_start_in_relative": { + "description": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_start_in_relative::description%]", + "name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_start_in_relative::name%]" + }, + "device_id": { + "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]", + "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]" + } + }, + "name": "Start selected program" } } } diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 2cf504e888c2ff..b54f663c1ce5b2 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -16,7 +16,7 @@ from .common import setup_home_connect_entry, should_add_option_entity from .const import BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, DOMAIN -from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry +from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry from .entity import HomeConnectEntity, HomeConnectOptionEntity from .utils import get_dict_from_home_connect_error @@ -170,36 +170,32 @@ def _get_entities_for_appliance( - entry: HomeConnectConfigEntry, - appliance: HomeConnectApplianceData, + appliance_coordinator: HomeConnectApplianceCoordinator, ) -> list[HomeConnectEntity]: """Get a list of entities.""" entities: list[HomeConnectEntity] = [] - if SettingKey.BSH_COMMON_POWER_STATE in appliance.settings: + if SettingKey.BSH_COMMON_POWER_STATE in appliance_coordinator.data.settings: entities.append( - HomeConnectPowerSwitch( - entry.runtime_data, appliance, POWER_SWITCH_DESCRIPTION - ) + HomeConnectPowerSwitch(appliance_coordinator, POWER_SWITCH_DESCRIPTION) ) entities.extend( - HomeConnectSwitch(entry.runtime_data, appliance, description) + HomeConnectSwitch(appliance_coordinator, description) for description in SWITCHES - if description.key in appliance.settings + if description.key in appliance_coordinator.data.settings ) return entities def _get_option_entities_for_appliance( - entry: HomeConnectConfigEntry, - appliance: HomeConnectApplianceData, + appliance_coordinator: HomeConnectApplianceCoordinator, entity_registry: er.EntityRegistry, -) -> list[HomeConnectOptionEntity]: +) -> list[HomeConnectEntity]: """Get a list of currently available option entities.""" return [ - HomeConnectSwitchOptionEntity(entry.runtime_data, appliance, description) + HomeConnectSwitchOptionEntity(appliance_coordinator, description) for description in SWITCH_OPTIONS if should_add_option_entity( - description, appliance, entity_registry, Platform.SWITCH + description, appliance_coordinator.data, entity_registry, Platform.SWITCH ) ] diff --git a/homeassistant/components/homeassistant/repairs.py b/homeassistant/components/homeassistant/repairs.py index cff123da17aac3..d631c13b569dac 100644 --- a/homeassistant/components/homeassistant/repairs.py +++ b/homeassistant/components/homeassistant/repairs.py @@ -50,6 +50,44 @@ async def async_step_ignore( ) +class OrphanedConfigEntryFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, data: dict[str, str]) -> None: + """Initialize.""" + self.entry_id = data["entry_id"] + self.description_placeholders = data + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the first step of a fix flow.""" + return self.async_show_menu( + step_id="init", + menu_options=["confirm", "ignore"], + description_placeholders=self.description_placeholders, + ) + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the confirm step of a fix flow.""" + await self.hass.config_entries.async_remove(self.entry_id) + return self.async_create_entry(data={}) + + async def async_step_ignore( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the ignore step of a fix flow.""" + ir.async_get(self.hass).async_ignore( + DOMAIN, f"orphaned_ignored_entry.{self.entry_id}", True + ) + return self.async_abort( + reason="issue_ignored", + description_placeholders=self.description_placeholders, + ) + + async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, data: dict[str, str] | None ) -> RepairsFlow: @@ -58,4 +96,7 @@ async def async_create_fix_flow( if issue_id.split(".", maxsplit=1)[0] == "integration_not_found": assert data return IntegrationNotFoundFlow(data) + if issue_id.split(".", maxsplit=1)[0] == "orphaned_ignored_entry": + assert data + return OrphanedConfigEntryFlow(data) return ConfirmRepairFlow() diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index e23a165005d76a..16cad4835abde7 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -27,6 +27,15 @@ "multiple_integration_config_errors": { "message": "Failed to process config for integration {domain} due to multiple ({errors}) errors. Check the logs for more information." }, + "oauth2_helper_reauth_required": { + "message": "Credentials are invalid, re-authentication required" + }, + "oauth2_helper_refresh_failed": { + "message": "OAuth2 token refresh failed for {domain}" + }, + "oauth2_helper_refresh_transient": { + "message": "Temporary error refreshing credentials for {domain}, try again later" + }, "platform_component_load_err": { "message": "Platform error: {domain} - {error}." }, @@ -162,6 +171,24 @@ "description": "It's not possible to configure {platform} {domain} by adding `{platform_key}` to the {domain} configuration. Please check the documentation for more information on how to set up this integration.\n\nTo resolve this:\n1. Remove `{platform_key}` occurrences from the `{domain}:` configuration in your YAML configuration file.\n2. Restart Home Assistant.\n\nExample that should be removed:\n{yaml_example}", "title": "Unused YAML configuration for the {platform} integration" }, + "orphaned_ignored_config_entry": { + "fix_flow": { + "abort": { + "issue_ignored": "Non-existent integration {domain} ignored." + }, + "step": { + "init": { + "description": "There is an ignored orphaned config entry for the `{domain}` integration. This can happen when an integration is removed, but the config entry is still present in Home Assistant.\n\nTo resolve this, press **Remove** to clean up the orphaned entry.", + "menu_options": { + "confirm": "Remove", + "ignore": "Ignore" + }, + "title": "[%key:component::homeassistant::issues::orphaned_ignored_config_entry::title%]" + } + } + }, + "title": "Orphaned ignored config entry for {domain}" + }, "platform_only": { "description": "The {domain} integration does not support configuration under its own key, it must be configured under its supported platforms.\n\nTo resolve this:\n\n1. Remove `{domain}:` from your YAML configuration file.\n\n2. Restart Home Assistant.", "title": "The {domain} integration does not support YAML configuration under its own key" diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index 644d95e281a515..3545c080e089a8 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -4,16 +4,16 @@ "abort": { "fw_download_failed": "{firmware_name} firmware for your {model} failed to download. Make sure Home Assistant has internet access and try again.", "fw_install_failed": "{firmware_name} firmware failed to install, check Home Assistant logs for more information.", - "not_hassio_thread": "The OpenThread Border Router add-on can only be installed with Home Assistant OS. If you would like to use the {model} as a Thread border router, please manually set up OpenThread Border Router to communicate with it.", - "otbr_addon_already_running": "The OpenThread Border Router add-on is already running, it cannot be installed again.", - "otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router add-on. If you use the Thread network, make sure you have alternative border routers. Uninstall the add-on and try again.", - "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device.", + "not_hassio_thread": "The OpenThread Border Router app can only be installed with Home Assistant OS. If you would like to use the {model} as a Thread border router, please manually set up OpenThread Border Router to communicate with it.", + "otbr_addon_already_running": "The OpenThread Border Router app is already running, it cannot be installed again.", + "otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router app. If you use the Thread network, make sure you have alternative border routers. Uninstall the app and try again.", + "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or app is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device.", "zha_still_using_stick": "This {model} is in use by the Zigbee Home Automation integration. Please migrate your Zigbee network to another adapter or delete the integration and try again." }, "progress": { "install_firmware": "Installing {firmware_name} firmware.\n\nDo not make any changes to your hardware or software until this finishes.", - "install_otbr_addon": "Installing add-on", - "start_otbr_addon": "Starting add-on" + "install_otbr_addon": "Installing app", + "start_otbr_addon": "Starting app" }, "step": { "confirm_otbr": { @@ -34,7 +34,7 @@ "title": "Updating adapter" }, "otbr_failed": { - "description": "The OpenThread Border Router add-on installation was unsuccessful. Ensure no other software is trying to communicate with the {model}, you have access to the Internet and can install other add-ons, and try again. Check the Supervisor logs if the problem persists.", + "description": "The OpenThread Border Router app installation was unsuccessful. Ensure no other software is trying to communicate with the {model}, you have access to the Internet and can install other apps, and try again. Check the Supervisor logs if the problem persists.", "title": "Failed to set up OpenThread Border Router" }, "pick_firmware": { @@ -89,11 +89,11 @@ "silabs_multiprotocol_hardware": { "options": { "abort": { - "addon_already_running": "Failed to start the {addon_name} add-on because it is already running.", - "addon_info_failed": "Failed to get {addon_name} add-on info.", - "addon_install_failed": "Failed to install the {addon_name} add-on.", + "addon_already_running": "Failed to start the {addon_name} app because it is already running.", + "addon_info_failed": "Failed to get {addon_name} app info.", + "addon_install_failed": "Failed to install the {addon_name} app.", "addon_set_config_failed": "Failed to set {addon_name} configuration.", - "addon_start_failed": "Failed to start the {addon_name} add-on.", + "addon_start_failed": "Failed to start the {addon_name} app.", "not_hassio": "The hardware options can only be configured on Home Assistant OS installations.", "zha_migration_failed": "The ZHA migration did not succeed." }, @@ -101,8 +101,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "progress": { - "install_addon": "Please wait while the {addon_name} add-on installation finishes. This can take several minutes.", - "start_addon": "Please wait while the {addon_name} add-on start completes. This may take some seconds." + "install_addon": "Please wait while the {addon_name} app installation finishes. This can take several minutes.", + "start_addon": "Please wait while the {addon_name} app start completes. This may take some seconds." }, "step": { "addon_installed_other_device": { @@ -129,7 +129,7 @@ "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]" }, "install_addon": { - "title": "The Silicon Labs Multiprotocol add-on installation has started" + "title": "The Silicon Labs Multiprotocol app installation has started" }, "notify_channel_change": { "description": "A Zigbee and Thread channel change has been initiated and will finish in {delay_minutes} minutes.", @@ -143,7 +143,7 @@ "title": "Reconfigure IEEE 802.15.4 radio multiprotocol support" }, "start_addon": { - "title": "The Silicon Labs Multiprotocol add-on is starting." + "title": "The Silicon Labs Multiprotocol app is starting." }, "uninstall_addon": { "data": { diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index ed74b5f07af238..aacf51da97d4d5 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -25,7 +25,7 @@ "otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", "read_hw_settings_error": "Failed to read hardware settings", - "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device.", + "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or app is currently trying to communicate with the device.", "write_hw_settings_error": "Failed to write hardware settings", "zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]", "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]" diff --git a/homeassistant/components/homee/config_flow.py b/homeassistant/components/homee/config_flow.py index 44c9b70953bc9a..87b23e1bd6516d 100644 --- a/homeassistant/components/homee/config_flow.py +++ b/homeassistant/components/homee/config_flow.py @@ -11,7 +11,12 @@ ) import voluptuous as vol -from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_USER, + ConfigEntryState, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -113,7 +118,22 @@ async def async_step_zeroconf( if discovery_info.ip_address.version == 6: return self.async_abort(reason="ipv6_address") - await self.async_set_unique_id(self._name) + # If an already configured homee reports with a second IP, abort. + existing_entry = await self.async_set_unique_id(self._name) + if ( + existing_entry + and existing_entry.state == ConfigEntryState.LOADED + and existing_entry.runtime_data.connected + and existing_entry.data[CONF_HOST] != self._host + ): + _LOGGER.debug( + "Aborting config flow for discovered homee with IP %s " + "since it is already configured at IP %s", + self._host, + existing_entry.data[CONF_HOST], + ) + return self.async_abort(reason="2nd_ip_address") + self._abort_if_unique_id_configured(updates={CONF_HOST: self._host}) # Cause an auth-error to see if homee is reachable. diff --git a/homeassistant/components/homee/const.py b/homeassistant/components/homee/const.py index 718baf346ae7be..c542de0a0aa3d3 100644 --- a/homeassistant/components/homee/const.py +++ b/homeassistant/components/homee/const.py @@ -31,6 +31,7 @@ "n/a": None, "text": None, "%": PERCENTAGE, + "Lux": LIGHT_LUX, "lx": LIGHT_LUX, "klx": LIGHT_LUX, "1/min": REVOLUTIONS_PER_MINUTE, diff --git a/homeassistant/components/homee/event.py b/homeassistant/components/homee/event.py index 5c4fa0af38013d..1ea5058abf295d 100644 --- a/homeassistant/components/homee/event.py +++ b/homeassistant/components/homee/event.py @@ -20,6 +20,7 @@ REMOTE_PROFILES = [ NodeProfile.REMOTE, + NodeProfile.ONE_BUTTON_REMOTE, NodeProfile.TWO_BUTTON_REMOTE, NodeProfile.THREE_BUTTON_REMOTE, NodeProfile.FOUR_BUTTON_REMOTE, diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index 9187c9956c7014..4bb1339ddff6ce 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "2nd_ip_address": "Your homee is already connected using another IP address", "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%]", diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 4aaec4a98409ea..7748f86b9acd60 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -10,7 +10,8 @@ "loggers": ["pyhap"], "requirements": [ "HAP-python==5.0.0", - "fnv-hash-fast==1.6.0", + "fnv-hash-fast==2.0.0", + "homekit-audio-proxy==1.2.1", "PyQRCode==1.2.1", "base36==0.1.1" ], diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index cb5de0265014f1..87802bf1661eea 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -6,6 +6,7 @@ from typing import Any from haffmpeg.core import FFMPEG_STDERR, HAFFmpeg +from homekit_audio_proxy import AudioProxy from pyhap.camera import ( VIDEO_CODEC_PARAM_LEVEL_TYPES, VIDEO_CODEC_PARAM_PROFILE_ID_TYPES, @@ -89,11 +90,10 @@ "{a_application}" "-ac 1 -ar {a_sample_rate}k " "-b:a {a_max_bitrate}k -bufsize {a_bufsize}k " + "{a_frame_duration}" "-payload_type 110 " "-ssrc {a_ssrc} -f rtp " - "-srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params {a_srtp_key} " - "srtp://{address}:{a_port}?rtcpport={a_port}&" - "localrtpport={a_port}&pkt_size={a_pkt_size}" + "rtp://127.0.0.1:{a_proxy_port}?pkt_size={a_pkt_size}" ) SLOW_RESOLUTIONS = [ @@ -120,6 +120,7 @@ FFMPEG_LOGGER = "ffmpeg_logger" FFMPEG_WATCHER = "ffmpeg_watcher" FFMPEG_PID = "ffmpeg_pid" +AUDIO_PROXY = "audio_proxy" SESSION_ID = "session_id" CONFIG_DEFAULTS = { @@ -339,8 +340,33 @@ async def start_stream( + " " ) audio_application = "" + audio_frame_duration = "" if self.config[CONF_AUDIO_CODEC] == "libopus": audio_application = "-application lowdelay " + audio_frame_duration = ( + f"-frame_duration {stream_config.get('a_packet_time', 20)} " + ) + # Start audio proxy to convert Opus RTP timestamps from 48kHz + # (FFmpeg's hardcoded Opus RTP clock rate per RFC 7587) to the + # sample rate negotiated by HomeKit (typically 16kHz). + # a_sample_rate is in kHz (e.g. 16 for 16000 Hz) from pyhap TLV. + audio_proxy: AudioProxy | None = None + if self.config[CONF_SUPPORT_AUDIO]: + audio_proxy = AudioProxy( + dest_addr=stream_config["address"], + dest_port=stream_config["a_port"], + srtp_key_b64=stream_config["a_srtp_key"], + target_clock_rate=stream_config["a_sample_rate"] * 1000, + ) + await audio_proxy.async_start() + if not audio_proxy.local_port: + _LOGGER.error( + "[%s] Audio proxy failed to start", + self.display_name, + ) + await audio_proxy.async_stop() + audio_proxy = None + output_vars = stream_config.copy() output_vars.update( { @@ -354,6 +380,8 @@ async def start_stream( "a_pkt_size": self.config[CONF_AUDIO_PACKET_SIZE], "a_encoder": self.config[CONF_AUDIO_CODEC], "a_application": audio_application, + "a_frame_duration": audio_frame_duration, + "a_proxy_port": audio_proxy.local_port if audio_proxy else 0, } ) output = VIDEO_OUTPUT.format(**output_vars) @@ -371,6 +399,8 @@ async def start_stream( ) if not opened: _LOGGER.error("Failed to open ffmpeg stream") + if audio_proxy: + await audio_proxy.async_stop() return False _LOGGER.debug( @@ -381,6 +411,7 @@ async def start_stream( session_info["stream"] = stream session_info[FFMPEG_PID] = stream.process.pid + session_info[AUDIO_PROXY] = audio_proxy stderr_reader = await stream.get_reader(source=FFMPEG_STDERR) @@ -441,6 +472,9 @@ def async_stop(self) -> None: async def stop_stream(self, session_info: dict[str, Any]) -> None: """Stop the stream for the given ``session_id``.""" session_id = session_info["id"] + if proxy := session_info.pop(AUDIO_PROXY, None): + await proxy.async_stop() + if not (stream := session_info.get("stream")): _LOGGER.debug("No stream for session ID %s", session_id) return diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 9e7675b5774efc..ebf1bd97c5be60 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -26,7 +26,7 @@ DEFAULT_MAX_TEMP, DEFAULT_MIN_HUMIDITY, DEFAULT_MIN_TEMP, - DOMAIN as DOMAIN_CLIMATE, + DOMAIN as CLIMATE_DOMAIN, FAN_AUTO, FAN_HIGH, FAN_LOW, @@ -49,7 +49,7 @@ HVACMode, ) from homeassistant.components.water_heater import ( - DOMAIN as DOMAIN_WATER_HEATER, + DOMAIN as WATER_HEATER_DOMAIN, SERVICE_SET_TEMPERATURE as SERVICE_SET_TEMPERATURE_WATER_HEATER, ) from homeassistant.const import ( @@ -388,13 +388,13 @@ def _set_fan_swing_mode(self, swing_on: int) -> None: _LOGGER.debug("%s: Set swing mode to %s", self.entity_id, swing_on) mode = self.swing_on_mode if swing_on else SWING_OFF params = {ATTR_ENTITY_ID: self.entity_id, ATTR_SWING_MODE: mode} - self.async_call_service(DOMAIN_CLIMATE, SERVICE_SET_SWING_MODE, params) + self.async_call_service(CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE, params) def _set_fan_speed(self, speed: int) -> None: _LOGGER.debug("%s: Set fan speed to %s", self.entity_id, speed) mode = percentage_to_ordered_list_item(self.ordered_fan_speeds, speed - 1) params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode} - self.async_call_service(DOMAIN_CLIMATE, SERVICE_SET_FAN_MODE, params) + self.async_call_service(CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, params) def _get_on_mode(self) -> str: if self.ordered_fan_speeds: @@ -412,13 +412,13 @@ def _set_fan_active(self, active: int) -> None: return mode = self._get_on_mode() if active else self.fan_modes[FAN_OFF] params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode} - self.async_call_service(DOMAIN_CLIMATE, SERVICE_SET_FAN_MODE, params) + self.async_call_service(CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, params) def _set_fan_auto(self, auto: int) -> None: _LOGGER.debug("%s: Set fan auto to %s", self.entity_id, auto) mode = self.fan_modes[FAN_AUTO] if auto else self._get_on_mode() params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode} - self.async_call_service(DOMAIN_CLIMATE, SERVICE_SET_FAN_MODE, params) + self.async_call_service(CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, params) def _temperature_to_homekit(self, temp: float) -> float: return temperature_to_homekit(temp, self._unit) @@ -480,7 +480,7 @@ def _set_chars(self, char_values: dict[str, Any]) -> None: # `SERVICE_SET_HVAC_MODE_THERMOSTAT` before calling `SERVICE_SET_TEMPERATURE_THERMOSTAT` # to ensure the device is in the right mode before setting the temp. self.async_call_service( - DOMAIN_CLIMATE, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE_THERMOSTAT, params.copy(), ", ".join(events), @@ -557,7 +557,7 @@ def _set_chars(self, char_values: dict[str, Any]) -> None: if service: self.async_call_service( - DOMAIN_CLIMATE, + CLIMATE_DOMAIN, service, params, ", ".join(events), @@ -608,7 +608,7 @@ def set_target_humidity(self, value: float) -> None: _LOGGER.debug("%s: Set target humidity to %d", self.entity_id, value) params = {ATTR_ENTITY_ID: self.entity_id, ATTR_HUMIDITY: value} self.async_call_service( - DOMAIN_CLIMATE, SERVICE_SET_HUMIDITY, params, f"{value}{PERCENTAGE}" + CLIMATE_DOMAIN, SERVICE_SET_HUMIDITY, params, f"{value}{PERCENTAGE}" ) @callback @@ -804,7 +804,7 @@ def set_target_temperature(self, value: float) -> None: temperature = temperature_to_states(value, self._unit) params = {ATTR_ENTITY_ID: self.entity_id, ATTR_TEMPERATURE: temperature} self.async_call_service( - DOMAIN_WATER_HEATER, + WATER_HEATER_DOMAIN, SERVICE_SET_TEMPERATURE_WATER_HEATER, params, f"{temperature}{self._unit}", diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index a3fba48ef9c220..6a6252b434cf37 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -965,7 +965,7 @@ async def async_update( # visible on the network. self.async_set_available_state(False) return - except AccessoryDisconnectedError, EncryptionError: + except AccessoryDisconnectedError, EncryptionError, TimeoutError: # Temporary connection failure. Device may still available but our # connection was dropped or we are reconnecting self._poll_failures += 1 diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index 4ce57afe9466c7..41d965fab11064 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -3,6 +3,7 @@ from datetime import datetime from functools import partial import logging +from typing import Any from pyhomematic import HMConnection import voluptuous as vol @@ -215,8 +216,11 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DATA_CONF] = remotes = {} hass.data[DATA_STORE] = set() + interfaces: dict[str, dict[str, Any]] = conf[CONF_INTERFACES] + hosts: dict[str, dict[str, Any]] = conf[CONF_HOSTS] + # Create hosts-dictionary for pyhomematic - for rname, rconfig in conf[CONF_INTERFACES].items(): + for rname, rconfig in interfaces.items(): remotes[rname] = { "ip": rconfig.get(CONF_HOST), "port": rconfig.get(CONF_PORT), @@ -232,7 +236,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: "connect": True, } - for sname, sconfig in conf[CONF_HOSTS].items(): + for sname, sconfig in hosts.items(): remotes[sname] = { "ip": sconfig.get(CONF_HOST), "port": sconfig[CONF_PORT], @@ -258,7 +262,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, hass.data[DATA_HOMEMATIC].stop) # Init homematic hubs - entity_hubs = [HMHub(hass, homematic, hub_name) for hub_name in conf[CONF_HOSTS]] + entity_hubs = [HMHub(hass, homematic, hub_name) for hub_name in hosts] def _hm_service_virtualkey(service: ServiceCall) -> None: """Service to handle virtualkey servicecalls.""" @@ -294,7 +298,7 @@ def _hm_service_virtualkey(service: ServiceCall) -> None: def _service_handle_value(service: ServiceCall) -> None: """Service to call setValue method for HomeMatic system variable.""" - entity_ids = service.data.get(ATTR_ENTITY_ID) + entity_ids: list[str] | None = service.data.get(ATTR_ENTITY_ID) name = service.data[ATTR_NAME] value = service.data[ATTR_VALUE] diff --git a/homeassistant/components/homematic/entity.py b/homeassistant/components/homematic/entity.py index 3e4d6a6fc71541..9a153eb0aa8c69 100644 --- a/homeassistant/components/homematic/entity.py +++ b/homeassistant/components/homematic/entity.py @@ -11,6 +11,7 @@ from pyhomematic.devicetypes.generic import HMGeneric from homeassistant.const import ATTR_NAME +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.event import track_time_interval @@ -45,15 +46,16 @@ def __init__( entity_description: EntityDescription | None = None, ) -> None: """Initialize a generic HomeMatic device.""" - self._name = config.get(ATTR_NAME) + self._attr_name = config.get(ATTR_NAME) self._address = config.get(ATTR_ADDRESS) self._interface = config.get(ATTR_INTERFACE) self._channel = config.get(ATTR_CHANNEL) self._state = config.get(ATTR_PARAM) - self._unique_id = config.get(ATTR_UNIQUE_ID) + if unique_id := config.get(ATTR_UNIQUE_ID): + self._attr_unique_id = unique_id.replace(" ", "_") self._data: dict[str, Any] = {} self._connected = False - self._available = False + self._attr_available = False self._channel_map: dict[str, str] = {} if entity_description is not None: @@ -68,22 +70,7 @@ async def async_added_to_hass(self) -> None: self._subscribe_homematic_events() @property - def unique_id(self): - """Return unique ID. HomeMatic entity IDs are unique by default.""" - return self._unique_id.replace(" ", "_") - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def available(self) -> bool: - """Return true if device is available.""" - return self._available - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return device specific state attributes.""" # Static attributes attr = { @@ -116,7 +103,7 @@ def update(self) -> None: self._load_data_from_hm() # Link events from pyhomematic - self._available = not self._hmdevice.UNREACH + self._attr_available = not self._hmdevice.UNREACH except Exception as err: # noqa: BLE001 self._connected = False _LOGGER.error("Exception while linking %s: %s", self._address, str(err)) @@ -132,7 +119,7 @@ def _hm_event_callback(self, device, caller, attribute, value): # Availability has changed if self.available != (not self._hmdevice.UNREACH): - self._available = not self._hmdevice.UNREACH + self._attr_available = not self._hmdevice.UNREACH has_changed = True # If it has changed data point, update Home Assistant @@ -213,14 +200,14 @@ class HMHub(Entity): _attr_should_poll = False - def __init__(self, hass, homematic, name): + def __init__(self, hass: HomeAssistant, homematic: HMConnection, name: str) -> None: """Initialize HomeMatic hub.""" self.hass = hass self.entity_id = f"{DOMAIN}.{name.lower()}" self._homematic = homematic - self._variables = {} + self._variables: dict[str, Any] = {} self._name = name - self._state = None + self._state: int | None = None # Load data track_time_interval(self.hass, self._update_hub, SCAN_INTERVAL_HUB) @@ -230,22 +217,22 @@ def __init__(self, hass, homematic, name): self.hass.add_job(self._update_variables, None) @property - def name(self): + def name(self) -> str: """Return the name of the device.""" return self._name @property - def state(self): + def state(self) -> int | None: """Return the state of the entity.""" return self._state @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return self._variables.copy() @property - def icon(self): + def icon(self) -> str: """Return the icon to use in the frontend, if any.""" return "mdi:gradient-vertical" diff --git a/homeassistant/components/homematic/sensor.py b/homeassistant/components/homematic/sensor.py index 0ddc319626e0c8..04b6546674cd7a 100644 --- a/homeassistant/components/homematic/sensor.py +++ b/homeassistant/components/homematic/sensor.py @@ -344,4 +344,4 @@ def _init_data_struct(self) -> None: if self._state: self._data.update({self._state: None}) else: - _LOGGER.critical("Unable to initialize sensor: %s", self._name) + _LOGGER.critical("Unable to initialize sensor: %s", self.name) diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py index 18f169bb91b14f..689bce9243f4ba 100644 --- a/homeassistant/components/homematicip_cloud/climate.py +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -88,6 +88,17 @@ def __init__(self, hap: HomematicipHAP, device: HeatingGroup) -> None: if device.actualTemperature is None: self._simple_heating = self._first_radiator_thermostat + @property + def available(self) -> bool: + """Heating group available. + + A heating group must be available, and should not be affected by the + individual availability of group members. + This allows controlling the temperature even when individual group + members are not available. + """ + return True + @property def device_info(self) -> DeviceInfo: """Return device specific attributes.""" diff --git a/homeassistant/components/homematicip_cloud/config_flow.py b/homeassistant/components/homematicip_cloud/config_flow.py index 9a9e1cb6778e10..3a8614b99592e4 100644 --- a/homeassistant/components/homematicip_cloud/config_flow.py +++ b/homeassistant/components/homematicip_cloud/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any import voluptuous as vol @@ -70,6 +71,11 @@ async def async_step_link(self, user_input: None = None) -> ConfigFlowResult: authtoken = await self.auth.async_register() if authtoken: _LOGGER.debug("Write config entry for HomematicIP Cloud") + if self.source == "reauth": + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={HMIPC_AUTHTOKEN: authtoken}, + ) return self.async_create_entry( title=self.auth.config[HMIPC_HAPID], data={ @@ -78,11 +84,50 @@ async def async_step_link(self, user_input: None = None) -> ConfigFlowResult: HMIPC_NAME: self.auth.config.get(HMIPC_NAME), }, ) - return self.async_abort(reason="connection_aborted") - errors["base"] = "press_the_button" + if self.source == "reauth": + errors["base"] = "connection_aborted" + else: + return self.async_abort(reason="connection_aborted") + else: + errors["base"] = "press_the_button" return self.async_show_form(step_id="link", errors=errors) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauthentication when the auth token becomes invalid.""" + 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 and start link process.""" + errors = {} + reauth_entry = self._get_reauth_entry() + + if user_input is not None: + config = { + HMIPC_HAPID: reauth_entry.data[HMIPC_HAPID], + HMIPC_PIN: user_input.get(HMIPC_PIN), + HMIPC_NAME: reauth_entry.data.get(HMIPC_NAME), + } + self.auth = HomematicipAuth(self.hass, config) + connected = await self.auth.async_setup() + if connected: + return await self.async_step_link() + errors["base"] = "invalid_sgtin_or_pin" + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Optional(HMIPC_PIN): str, + } + ), + errors=errors, + ) + async def async_step_import(self, import_data: dict[str, str]) -> ConfigFlowResult: """Import a new access point as a config entry.""" hapid = import_data[HMIPC_HAPID].replace("-", "").upper() diff --git a/homeassistant/components/homematicip_cloud/const.py b/homeassistant/components/homematicip_cloud/const.py index d4c0b1a45cafb5..07e4fbadeb7ae5 100644 --- a/homeassistant/components/homematicip_cloud/const.py +++ b/homeassistant/components/homematicip_cloud/const.py @@ -18,6 +18,7 @@ Platform.LIGHT, Platform.LOCK, Platform.SENSOR, + Platform.SIREN, Platform.SWITCH, Platform.VALVE, Platform.WEATHER, diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py index 8a3abb5156c835..a8070c455d1aff 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -312,6 +312,17 @@ def __init__(self, hap: HomematicipHAP, device, post: str = "ShutterGroup") -> N device.modelType = f"HmIP-{post}" super().__init__(hap, device, post, is_multi_channel=False) + @property + def available(self) -> bool: + """Cover shutter group available. + + A cover shutter group must be available, and should not be affected by + the individual availability of group members. + This allows controlling the shutters even when individual group + members are not available. + """ + return True + @property def current_cover_position(self) -> int | None: """Return current position of cover.""" diff --git a/homeassistant/components/homematicip_cloud/diagnostics.py b/homeassistant/components/homematicip_cloud/diagnostics.py new file mode 100644 index 00000000000000..64f418cbcc0f77 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/diagnostics.py @@ -0,0 +1,27 @@ +"""Diagnostics support for HomematicIP Cloud.""" + +from __future__ import annotations + +import json +from typing import Any + +from homematicip.base.helpers import handle_config + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from .hap import HomematicIPConfigEntry + +TO_REDACT_CONFIG = {"city", "latitude", "longitude", "refreshToken"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: HomematicIPConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + hap = config_entry.runtime_data + json_state = await hap.home.download_configuration_async() + anonymized = handle_config(json_state, anonymize=True) + config = json.loads(anonymized) + + return async_redact_data(config, TO_REDACT_CONFIG) diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index 304d5354b1b317..7d213e71e079e9 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -12,7 +12,10 @@ from homematicip.base.enums import EventType from homematicip.connection.connection_context import ConnectionContextBuilder from homematicip.connection.rest_connection import RestConnection -from homematicip.exceptions.connection_exceptions import HmipConnectionError +from homematicip.exceptions.connection_exceptions import ( + HmipAuthenticationError, + HmipConnectionError, +) import homeassistant from homeassistant.config_entries import ConfigEntry @@ -192,6 +195,12 @@ async def _try_get_state(self) -> None: try: await self.get_state() break + except HmipAuthenticationError: + _LOGGER.error( + "Authentication error from HomematicIP Cloud, triggering reauth" + ) + self.config_entry.async_start_reauth(self.hass) + break except HmipConnectionError as err: _LOGGER.warning( "Get_state failed, retrying in %s seconds: %s", delay, err diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py index e8b0681d059d57..6affad00b3fcc9 100644 --- a/homeassistant/components/homematicip_cloud/light.py +++ b/homeassistant/components/homematicip_cloud/light.py @@ -11,10 +11,14 @@ OpticalSignalBehaviour, RGBColorState, ) -from homematicip.base.functionalChannels import NotificationLightChannel +from homematicip.base.functionalChannels import ( + NotificationLightChannel, + NotificationMp3SoundChannel, +) from homematicip.device import ( BrandDimmer, BrandSwitchNotificationLight, + CombinationSignallingDevice, Device, Dimmer, DinRailDimmer3, @@ -22,6 +26,7 @@ PluggableDimmer, SwitchMeasuring, WiredDimmer3, + WiredPushButton, ) from packaging.version import Version @@ -54,7 +59,7 @@ async def async_setup_entry( entities: list[HomematicipGenericEntity] = [] entities.extend( - HomematicipLightHS(hap, d, ch.index) + HomematicipColorLight(hap, d, ch.index) for d in hap.home.devices for ch in d.functionalChannels if ch.functionalChannelType == FunctionalChannelType.UNIVERSAL_LIGHT_CHANNEL @@ -93,6 +98,22 @@ async def async_setup_entry( (Dimmer, PluggableDimmer, BrandDimmer, FullFlushDimmer), ): entities.append(HomematicipDimmer(hap, device)) + elif isinstance(device, WiredPushButton): + optical_channels = sorted( + ( + ch + for ch in device.functionalChannels + if ch.functionalChannelType + == FunctionalChannelType.OPTICAL_SIGNAL_CHANNEL + ), + key=lambda ch: ch.index, + ) + for led_number, ch in enumerate(optical_channels, start=1): + entities.append( + HomematicipOpticalSignalLight(hap, device, ch.index, led_number) + ) + elif isinstance(device, CombinationSignallingDevice): + entities.append(HomematicipCombinationSignallingLight(hap, device)) async_add_entities(entities) @@ -121,16 +142,32 @@ async def async_turn_off(self, **kwargs: Any) -> None: await self._device.turn_off_async() -class HomematicipLightHS(HomematicipGenericEntity, LightEntity): - """Representation of the HomematicIP light with HS color mode.""" - - _attr_color_mode = ColorMode.HS - _attr_supported_color_modes = {ColorMode.HS} +class HomematicipColorLight(HomematicipGenericEntity, LightEntity): + """Representation of the HomematicIP color light.""" 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) + def _supports_color(self) -> bool: + """Return true if device supports hue/saturation color control.""" + channel = self.get_channel_or_raise() + return channel.hue is not None and channel.saturationLevel is not None + + @property + def color_mode(self) -> ColorMode: + """Return the color mode of the light.""" + if self._supports_color(): + return ColorMode.HS + return ColorMode.BRIGHTNESS + + @property + def supported_color_modes(self) -> set[ColorMode]: + """Return the supported color modes.""" + if self._supports_color(): + return {ColorMode.HS} + return {ColorMode.BRIGHTNESS} + @property def is_on(self) -> bool: """Return true if light is on.""" @@ -157,18 +194,26 @@ def hs_color(self) -> tuple[float, float] | None: async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" channel = self.get_channel_or_raise() - hs_color = kwargs.get(ATTR_HS_COLOR, (0.0, 0.0)) - hue = hs_color[0] % 360.0 - saturation = hs_color[1] / 100.0 dim_level = round(kwargs.get(ATTR_BRIGHTNESS, 255) / 255.0, 2) - if ATTR_HS_COLOR not in kwargs: - hue = channel.hue - saturation = channel.saturationLevel - if ATTR_BRIGHTNESS not in kwargs: # If no brightness is set, use the current brightness dim_level = channel.dimLevel or 1.0 + + # Use dim-only method for monochrome mode (hue/saturation not supported) + if not self._supports_color(): + await channel.set_dim_level_async(dim_level=dim_level) + return + + # Full color mode with hue/saturation + if ATTR_HS_COLOR in kwargs: + hs_color = kwargs[ATTR_HS_COLOR] + hue = hs_color[0] % 360.0 + saturation = hs_color[1] / 100.0 + else: + hue = channel.hue + saturation = channel.saturationLevel + await channel.set_hue_saturation_dim_level_async( hue=hue, saturation_level=saturation, dim_level=dim_level ) @@ -421,3 +466,196 @@ def _convert_color(color: tuple) -> RGBColorState: if 270 < hue <= 330: return RGBColorState.PURPLE return RGBColorState.RED + + +class HomematicipOpticalSignalLight(HomematicipGenericEntity, LightEntity): + """Representation of HomematicIP WiredPushButton LED light.""" + + _attr_color_mode = ColorMode.HS + _attr_supported_color_modes = {ColorMode.HS} + _attr_supported_features = LightEntityFeature.EFFECT + _attr_translation_key = "optical_signal_light" + + _effect_to_behaviour: dict[str, OpticalSignalBehaviour] = { + "on": OpticalSignalBehaviour.ON, + "blinking": OpticalSignalBehaviour.BLINKING_MIDDLE, + "flash": OpticalSignalBehaviour.FLASH_MIDDLE, + "billow": OpticalSignalBehaviour.BILLOW_MIDDLE, + } + _behaviour_to_effect: dict[OpticalSignalBehaviour, str] = { + v: k for k, v in _effect_to_behaviour.items() + } + + _attr_effect_list = list(_effect_to_behaviour) + + _color_switcher: dict[str, tuple[float, float]] = { + RGBColorState.WHITE: (0.0, 0.0), + RGBColorState.RED: (0.0, 100.0), + RGBColorState.YELLOW: (60.0, 100.0), + RGBColorState.GREEN: (120.0, 100.0), + RGBColorState.TURQUOISE: (180.0, 100.0), + RGBColorState.BLUE: (240.0, 100.0), + RGBColorState.PURPLE: (300.0, 100.0), + } + + def __init__( + self, + hap: HomematicipHAP, + device: WiredPushButton, + channel_index: int, + led_number: int, + ) -> None: + """Initialize the optical signal light entity.""" + super().__init__( + hap, + device, + post=f"LED {led_number}", + channel=channel_index, + is_multi_channel=True, + channel_real_index=channel_index, + ) + + @property + def is_on(self) -> bool: + """Return true if light is on.""" + channel = self.get_channel_or_raise() + return channel.on is True + + @property + def brightness(self) -> int: + """Return the brightness of this light between 0..255.""" + channel = self.get_channel_or_raise() + return int((channel.dimLevel or 0.0) * 255) + + @property + def hs_color(self) -> tuple[float, float]: + """Return the hue and saturation color value [float, float].""" + channel = self.get_channel_or_raise() + simple_rgb_color = channel.simpleRGBColorState + return self._color_switcher.get(simple_rgb_color, (0.0, 0.0)) + + @property + def effect(self) -> str | None: + """Return the current effect.""" + channel = self.get_channel_or_raise() + return self._behaviour_to_effect.get(channel.opticalSignalBehaviour) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes of the optical signal light.""" + state_attr = super().extra_state_attributes + channel = self.get_channel_or_raise() + + if self.is_on: + state_attr[ATTR_COLOR_NAME] = channel.simpleRGBColorState + + return state_attr + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on.""" + # Use hs_color from kwargs, if not applicable use current hs_color. + hs_color = kwargs.get(ATTR_HS_COLOR, self.hs_color) + simple_rgb_color = _convert_color(hs_color) + + # If no kwargs, use default value. + brightness = 255 + if ATTR_BRIGHTNESS in kwargs: + brightness = kwargs[ATTR_BRIGHTNESS] + + # Minimum brightness is 10, otherwise the LED is disabled + brightness = max(10, brightness) + dim_level = round(brightness / 255.0, 2) + + effect = self.effect + if ATTR_EFFECT in kwargs: + effect = kwargs[ATTR_EFFECT] + elif effect is None: + effect = "on" + + behaviour = self._effect_to_behaviour.get(effect, OpticalSignalBehaviour.ON) + + await self._device.set_optical_signal_async( + channelIndex=self._channel, + opticalSignalBehaviour=behaviour, + rgb=simple_rgb_color, + dimLevel=dim_level, + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + channel = self.get_channel_or_raise() + simple_rgb_color = channel.simpleRGBColorState + + await self._device.set_optical_signal_async( + channelIndex=self._channel, + opticalSignalBehaviour=OpticalSignalBehaviour.OFF, + rgb=simple_rgb_color, + dimLevel=0.0, + ) + + +class HomematicipCombinationSignallingLight(HomematicipGenericEntity, LightEntity): + """Representation of the HomematicIP combination signalling device light (HmIP-MP3P).""" + + _attr_color_mode = ColorMode.HS + _attr_supported_color_modes = {ColorMode.HS} + + _color_switcher: dict[str, tuple[float, float]] = { + RGBColorState.WHITE: (0.0, 0.0), + RGBColorState.RED: (0.0, 100.0), + RGBColorState.YELLOW: (60.0, 100.0), + RGBColorState.GREEN: (120.0, 100.0), + RGBColorState.TURQUOISE: (180.0, 100.0), + RGBColorState.BLUE: (240.0, 100.0), + RGBColorState.PURPLE: (300.0, 100.0), + } + + def __init__( + self, hap: HomematicipHAP, device: CombinationSignallingDevice + ) -> None: + """Initialize the combination signalling light entity.""" + super().__init__(hap, device, channel=1, is_multi_channel=False) + + @property + def _func_channel(self) -> NotificationMp3SoundChannel: + return self._device.functionalChannels[self._channel] + + @property + def is_on(self) -> bool: + """Return true if light is on.""" + return self._func_channel.on + + @property + def brightness(self) -> int: + """Return the brightness of this light between 0..255.""" + return int((self._func_channel.dimLevel or 0.0) * 255) + + @property + def hs_color(self) -> tuple[float, float]: + """Return the hue and saturation color value [float, float].""" + simple_rgb_color = self._func_channel.simpleRGBColorState + return self._color_switcher.get(simple_rgb_color, (0.0, 0.0)) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on.""" + hs_color = kwargs.get(ATTR_HS_COLOR, self.hs_color) + simple_rgb_color = _convert_color(hs_color) + + brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness) + + # Default to full brightness when no kwargs given + if not kwargs: + brightness = 255 + + # Minimum brightness is 10, otherwise the LED is disabled + brightness = max(10, brightness) + dim_level = brightness / 255.0 + + await self._func_channel.set_rgb_dim_level_async( + rgb_color_state=simple_rgb_color.name, + dim_level=dim_level, + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + await self._func_channel.turn_off_async() diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 923c1418edb63d..e8192660fe5053 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.6.0"] + "requirements": ["homematicip==2.7.0"] } diff --git a/homeassistant/components/homematicip_cloud/siren.py b/homeassistant/components/homematicip_cloud/siren.py new file mode 100644 index 00000000000000..5fb4d73a27b35b --- /dev/null +++ b/homeassistant/components/homematicip_cloud/siren.py @@ -0,0 +1,86 @@ +"""Support for HomematicIP Cloud sirens.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homematicip.base.functionalChannels import NotificationMp3SoundChannel +from homematicip.device import CombinationSignallingDevice + +from homeassistant.components.siren import ( + ATTR_TONE, + ATTR_VOLUME_LEVEL, + SirenEntity, + SirenEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import HomematicipGenericEntity +from .hap import HomematicIPConfigEntry, HomematicipHAP + +_logger = logging.getLogger(__name__) + +# Map tone integers to HmIP sound file strings +_TONE_TO_SOUNDFILE: dict[int, str] = {0: "INTERNAL_SOUNDFILE"} +_TONE_TO_SOUNDFILE.update({i: f"SOUNDFILE_{i:03d}" for i in range(1, 253)}) + +# Available tones as dict[int, str] for HA UI +AVAILABLE_TONES: dict[int, str] = {0: "Internal"} +AVAILABLE_TONES.update({i: f"Sound {i}" for i in range(1, 253)}) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomematicIPConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the HomematicIP Cloud sirens from a config entry.""" + hap = config_entry.runtime_data + async_add_entities( + HomematicipMP3Siren(hap, device) + for device in hap.home.devices + if isinstance(device, CombinationSignallingDevice) + ) + + +class HomematicipMP3Siren(HomematicipGenericEntity, SirenEntity): + """Representation of the HomematicIP MP3 siren (HmIP-MP3P).""" + + _attr_available_tones = AVAILABLE_TONES + _attr_supported_features = ( + SirenEntityFeature.TURN_ON + | SirenEntityFeature.TURN_OFF + | SirenEntityFeature.TONES + | SirenEntityFeature.VOLUME_SET + ) + + def __init__( + self, hap: HomematicipHAP, device: CombinationSignallingDevice + ) -> None: + """Initialize the siren entity.""" + super().__init__(hap, device, post="Siren", channel=1, is_multi_channel=False) + + @property + def _func_channel(self) -> NotificationMp3SoundChannel: + return self._device.functionalChannels[self._channel] + + @property + def is_on(self) -> bool: + """Return true if siren is playing.""" + return self._func_channel.playingFileActive + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the siren on.""" + tone = kwargs.get(ATTR_TONE, 0) + volume_level = kwargs.get(ATTR_VOLUME_LEVEL, 1.0) + + sound_file = _TONE_TO_SOUNDFILE.get(tone, "INTERNAL_SOUNDFILE") + await self._func_channel.set_sound_file_volume_level_async( + sound_file=sound_file, volume_level=volume_level + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the siren off.""" + await self._func_channel.stop_sound_async() diff --git a/homeassistant/components/homematicip_cloud/strings.json b/homeassistant/components/homematicip_cloud/strings.json index e165a0b9c9110f..6fe481dd673a82 100644 --- a/homeassistant/components/homematicip_cloud/strings.json +++ b/homeassistant/components/homematicip_cloud/strings.json @@ -3,9 +3,11 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "connection_aborted": "[%key:common::config_flow::error::cannot_connect%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "error": { + "connection_aborted": "Registration failed, please try again.", "invalid_sgtin_or_pin": "Invalid SGTIN or PIN code, please try again.", "press_the_button": "Please press the blue button.", "register_failed": "Failed to register, please try again.", @@ -24,10 +26,31 @@ "link": { "description": "Press the blue button on the access point and the **Submit** button to register Homematic IP with Home Assistant.\n\n![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)", "title": "Link access point" + }, + "reauth_confirm": { + "data": { + "pin": "[%key:common::config_flow::data::pin%]" + }, + "description": "The authentication token for your HomematicIP access point is no longer valid. Press **Submit** and then press the blue button on your access point to re-register.", + "title": "Re-authenticate HomematicIP access point" } } }, "entity": { + "light": { + "optical_signal_light": { + "state_attributes": { + "effect": { + "state": { + "billow": "Billow", + "blinking": "Blinking", + "flash": "Flash", + "on": "[%key:common::state::on%]" + } + } + } + } + }, "sensor": { "smoke_detector_alarm_counter": { "name": "Alarm counter" diff --git a/homeassistant/components/homevolt/__init__.py b/homeassistant/components/homevolt/__init__.py index 97f0d684eb87b5..fb0f3093b28f93 100644 --- a/homeassistant/components/homevolt/__init__.py +++ b/homeassistant/components/homevolt/__init__.py @@ -10,7 +10,7 @@ from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: HomevoltConfigEntry) -> bool: diff --git a/homeassistant/components/homevolt/entity.py b/homeassistant/components/homevolt/entity.py new file mode 100644 index 00000000000000..7cfb14aa08332b --- /dev/null +++ b/homeassistant/components/homevolt/entity.py @@ -0,0 +1,67 @@ +"""Shared entity helpers for Homevolt.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from typing import Any, Concatenate + +from homevolt import HomevoltAuthenticationError, HomevoltConnectionError, HomevoltError + +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import HomevoltDataUpdateCoordinator + + +class HomevoltEntity(CoordinatorEntity[HomevoltDataUpdateCoordinator]): + """Base Homevolt entity.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: HomevoltDataUpdateCoordinator, device_identifier: str + ) -> None: + """Initialize the Homevolt entity.""" + super().__init__(coordinator) + device_id = coordinator.data.unique_id + device_metadata = coordinator.data.device_metadata.get(device_identifier) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{device_id}_{device_identifier}")}, + configuration_url=coordinator.client.base_url, + manufacturer=MANUFACTURER, + model=device_metadata.model if device_metadata else None, + name=device_metadata.name if device_metadata else None, + ) + + +def homevolt_exception_handler[_HomevoltEntityT: HomevoltEntity, **_P]( + func: Callable[Concatenate[_HomevoltEntityT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_HomevoltEntityT, _P], Coroutine[Any, Any, None]]: + """Decorate Homevolt calls to handle exceptions.""" + + async def handler( + self: _HomevoltEntityT, *args: _P.args, **kwargs: _P.kwargs + ) -> None: + try: + await func(self, *args, **kwargs) + except HomevoltAuthenticationError as error: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed", + ) from error + except HomevoltConnectionError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={"error": str(error)}, + ) from error + except HomevoltError 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/homevolt/manifest.json b/homeassistant/components/homevolt/manifest.json index 93e0ad3f56d789..3617cf26bc75d3 100644 --- a/homeassistant/components/homevolt/manifest.json +++ b/homeassistant/components/homevolt/manifest.json @@ -1,13 +1,13 @@ { "domain": "homevolt", "name": "Homevolt", - "codeowners": ["@danielhiversen"], + "codeowners": ["@danielhiversen", "@liudger"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homevolt", "integration_type": "device", "iot_class": "local_polling", - "quality_scale": "bronze", - "requirements": ["homevolt==0.4.4"], + "quality_scale": "silver", + "requirements": ["homevolt==0.5.0"], "zeroconf": [ { "name": "homevolt*", diff --git a/homeassistant/components/homevolt/quality_scale.yaml b/homeassistant/components/homevolt/quality_scale.yaml index 3e59352ce66c2f..a924f0a8a86a8a 100644 --- a/homeassistant/components/homevolt/quality_scale.yaml +++ b/homeassistant/components/homevolt/quality_scale.yaml @@ -33,13 +33,13 @@ rules: docs-configuration-parameters: status: exempt comment: Integration does not have an options flow. - docs-installation-parameters: todo + docs-installation-parameters: done entity-unavailable: done integration-owner: done - log-when-unavailable: todo + log-when-unavailable: done parallel-updates: done reauthentication-flow: done - test-coverage: todo + test-coverage: done # Gold devices: done diff --git a/homeassistant/components/homevolt/sensor.py b/homeassistant/components/homevolt/sensor.py index 43a69d85979ae8..9140fd3f64ee97 100644 --- a/homeassistant/components/homevolt/sensor.py +++ b/homeassistant/components/homevolt/sensor.py @@ -22,13 +22,11 @@ UnitOfTemperature, ) 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, MANUFACTURER from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator +from .entity import HomevoltEntity PARALLEL_UPDATES = 0 # Coordinator-based updates @@ -93,14 +91,14 @@ translation_key="energy_exported", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), SensorEntityDescription( key="energy_imported", translation_key="energy_imported", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), SensorEntityDescription( key="frequency", @@ -309,11 +307,10 @@ async def async_setup_entry( async_add_entities(entities) -class HomevoltSensor(CoordinatorEntity[HomevoltDataUpdateCoordinator], SensorEntity): +class HomevoltSensor(HomevoltEntity, SensorEntity): """Representation of a Homevolt sensor.""" entity_description: SensorEntityDescription - _attr_has_entity_name = True def __init__( self, @@ -322,24 +319,12 @@ def __init__( sensor_key: str, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator) - self.entity_description = description - unique_id = coordinator.data.unique_id - self._attr_unique_id = f"{unique_id}_{sensor_key}" sensor_data = coordinator.data.sensors[sensor_key] + super().__init__(coordinator, sensor_data.device_identifier) + self.entity_description = description + self._attr_unique_id = f"{coordinator.data.unique_id}_{sensor_key}" self._sensor_key = sensor_key - device_metadata = coordinator.data.device_metadata.get( - sensor_data.device_identifier - ) - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, f"{unique_id}_{sensor_data.device_identifier}")}, - configuration_url=coordinator.client.base_url, - manufacturer=MANUFACTURER, - model=device_metadata.model if device_metadata else None, - name=device_metadata.name if device_metadata else None, - ) - @property def available(self) -> bool: """Return if entity is available.""" diff --git a/homeassistant/components/homevolt/strings.json b/homeassistant/components/homevolt/strings.json index 931082fbca08cf..908443646c7fcd 100644 --- a/homeassistant/components/homevolt/strings.json +++ b/homeassistant/components/homevolt/strings.json @@ -160,6 +160,22 @@ "tmin": { "name": "Minimum temperature" } + }, + "switch": { + "local_mode": { + "name": "Local mode" + } + } + }, + "exceptions": { + "auth_failed": { + "message": "[%key:common::config_flow::error::invalid_auth%]" + }, + "communication_error": { + "message": "[%key:common::config_flow::error::cannot_connect%]" + }, + "unknown_error": { + "message": "[%key:common::config_flow::error::unknown%]" } } } diff --git a/homeassistant/components/homevolt/switch.py b/homeassistant/components/homevolt/switch.py new file mode 100644 index 00000000000000..1ce3efc1237ad8 --- /dev/null +++ b/homeassistant/components/homevolt/switch.py @@ -0,0 +1,55 @@ +"""Support for Homevolt switch entities.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.switch import SwitchEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator +from .entity import HomevoltEntity, homevolt_exception_handler + +PARALLEL_UPDATES = 0 # Coordinator-based updates + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HomevoltConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Homevolt switch entities.""" + coordinator = entry.runtime_data + async_add_entities([HomevoltLocalModeSwitch(coordinator)]) + + +class HomevoltLocalModeSwitch(HomevoltEntity, SwitchEntity): + """Switch entity for Homevolt local mode.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "local_mode" + + def __init__(self, coordinator: HomevoltDataUpdateCoordinator) -> None: + """Initialize the switch entity.""" + self._attr_unique_id = f"{coordinator.data.unique_id}_local_mode" + device_id = coordinator.data.unique_id + super().__init__(coordinator, f"ems_{device_id}") + + @property + def is_on(self) -> bool: + """Return true if local mode is enabled.""" + return self.coordinator.client.local_mode_enabled + + @homevolt_exception_handler + async def async_turn_on(self, **kwargs: Any) -> None: + """Enable local mode.""" + await self.coordinator.client.enable_local_mode() + await self.coordinator.async_request_refresh() + + @homevolt_exception_handler + async def async_turn_off(self, **kwargs: Any) -> None: + """Disable local mode.""" + await self.coordinator.client.disable_local_mode() + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 6e53a17861611a..3d15a34c7e7bc0 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -610,6 +610,7 @@ def uptime_to_datetime(value: int) -> datetime: key="active_liter_lpm", translation_key="active_liter_lpm", native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, state_class=SensorStateClass.MEASUREMENT, has_fn=lambda data: data.measurement.active_liter_lpm is not None, value_fn=lambda data: data.measurement.active_liter_lpm, diff --git a/homeassistant/components/hp_ilo/sensor.py b/homeassistant/components/hp_ilo/sensor.py index b4263f53d24cd4..e812535c936fbb 100644 --- a/homeassistant/components/hp_ilo/sensor.py +++ b/homeassistant/components/hp_ilo/sensor.py @@ -101,7 +101,6 @@ def setup_platform( devices = [] for monitored_variable in monitored_variables: new_device = HpIloSensor( - hass=hass, hp_ilo_data=hp_ilo_data, sensor_name=f"{config[CONF_NAME]} {monitored_variable[CONF_NAME]}", sensor_type=monitored_variable[CONF_SENSOR_TYPE], @@ -118,7 +117,6 @@ class HpIloSensor(SensorEntity): def __init__( self, - hass, hp_ilo_data, sensor_type, sensor_name, @@ -126,38 +124,14 @@ def __init__( unit_of_measurement, ): """Initialize the HP iLO sensor.""" - self._hass = hass - self._name = sensor_name - self._unit_of_measurement = unit_of_measurement + self._attr_name = sensor_name + self._attr_native_unit_of_measurement = unit_of_measurement self._ilo_function = SENSOR_TYPES[sensor_type][1] self.hp_ilo_data = hp_ilo_data self._sensor_value_template = sensor_value_template - self._state = None - self._state_attributes = None - _LOGGER.debug("Created HP iLO sensor %r", self) - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of the sensor.""" - return self._unit_of_measurement - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def extra_state_attributes(self): - """Return the device state attributes.""" - return self._state_attributes - def update(self) -> None: """Get the latest data from HP iLO and updates the states.""" # Call the API for new data. Each sensor will re-trigger this @@ -171,7 +145,7 @@ def update(self) -> None: ilo_data=ilo_data, parse_result=False ) - self._state = ilo_data + self._attr_native_value = ilo_data class HpIloData: diff --git a/homeassistant/components/html5/__init__.py b/homeassistant/components/html5/__init__.py index 4b85bf8ab8cd32..ed980a32ceeaf8 100644 --- a/homeassistant/components/html5/__init__.py +++ b/homeassistant/components/html5/__init__.py @@ -3,14 +3,26 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery +from homeassistant.helpers import config_validation as cv, discovery from .const import DOMAIN +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + +PLATFORMS = [Platform.NOTIFY] + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up HTML5 from a config entry.""" - await discovery.async_load_platform( - hass, Platform.NOTIFY, DOMAIN, dict(entry.data), {} + hass.async_create_task( + discovery.async_load_platform( + hass, Platform.NOTIFY, DOMAIN, dict(entry.data), {} + ) ) + 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/html5/config_flow.py b/homeassistant/components/html5/config_flow.py index def9d74b5d4b82..ae409d1366edfb 100644 --- a/homeassistant/components/html5/config_flow.py +++ b/homeassistant/components/html5/config_flow.py @@ -17,7 +17,6 @@ from homeassistant.core import callback from .const import ATTR_VAPID_EMAIL, ATTR_VAPID_PRV_KEY, ATTR_VAPID_PUB_KEY, DOMAIN -from .issues import async_create_html5_issue def vapid_generate_private_key() -> str: @@ -92,14 +91,3 @@ async def async_step_user( ), errors=errors, ) - - async def async_step_import( - self: HTML5ConfigFlow, import_config: dict - ) -> ConfigFlowResult: - """Handle config import from yaml.""" - _, flow_result = self._async_create_html5_entry(import_config) - if not flow_result: - async_create_html5_issue(self.hass, False) - return self.async_abort(reason="invalid_config") - async_create_html5_issue(self.hass, True) - return flow_result diff --git a/homeassistant/components/html5/issues.py b/homeassistant/components/html5/issues.py deleted file mode 100644 index 8892562d347e5e..00000000000000 --- a/homeassistant/components/html5/issues.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Issues utility for HTML5.""" - -import logging - -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -SUCCESSFUL_IMPORT_TRANSLATION_KEY = "deprecated_yaml" -FAILED_IMPORT_TRANSLATION_KEY = "deprecated_yaml_import_issue" - -INTEGRATION_TITLE = "HTML5 Push Notifications" - - -@callback -def async_create_html5_issue(hass: HomeAssistant, import_success: bool) -> None: - """Create issues for HTML5.""" - if import_success: - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2025.4.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": INTEGRATION_TITLE, - }, - ) - else: - async_create_issue( - hass, - DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2025.4.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml_import_issue", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": INTEGRATION_TITLE, - }, - ) diff --git a/homeassistant/components/html5/manifest.json b/homeassistant/components/html5/manifest.json index 59a755cbf06918..1ef261d201d860 100644 --- a/homeassistant/components/html5/manifest.json +++ b/homeassistant/components/html5/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/html5", "iot_class": "cloud_push", "loggers": ["http_ece", "py_vapid", "pywebpush"], - "requirements": ["pywebpush==2.3.0"], + "requirements": ["pywebpush==2.3.0", "py_vapid==1.9.4"], "single_config_entry": true } diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index 859a7b7e5678af..a5e823ce629cba 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -4,19 +4,19 @@ from contextlib import suppress from datetime import datetime, timedelta -from functools import partial from http import HTTPStatus import json import logging import time -from typing import Any +from typing import TYPE_CHECKING, Any, NotRequired, TypedDict, cast from urllib.parse import urlparse import uuid +from aiohttp import ClientError, ClientResponse, ClientSession, web from aiohttp.hdrs import AUTHORIZATION import jwt from py_vapid import Vapid -from pywebpush import WebPusher +from pywebpush import WebPusher, WebPushException, webpush_async import voluptuous as vol from voluptuous.humanize import humanize_error @@ -27,18 +27,23 @@ ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, - PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA, BaseNotificationService, + NotifyEntity, + NotifyEntityFeature, ) -from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.components.websocket_api import ActiveConnection +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_NAME, URL_ROOT -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback 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.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import ensure_unique_string -from homeassistant.util.json import JsonObjectType, load_json_object +from homeassistant.util.json import load_json_object from .const import ( ATTR_VAPID_EMAIL, @@ -47,23 +52,12 @@ DOMAIN, SERVICE_DISMISS, ) -from .issues import async_create_html5_issue _LOGGER = logging.getLogger(__name__) REGISTRATIONS_FILE = "html5_push_registrations.conf" -PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend( - { - vol.Optional("gcm_sender_id"): cv.string, - vol.Optional("gcm_api_key"): cv.string, - vol.Required(ATTR_VAPID_PUB_KEY): cv.string, - vol.Required(ATTR_VAPID_PRV_KEY): cv.string, - vol.Required(ATTR_VAPID_EMAIL): cv.string, - } -) - ATTR_SUBSCRIPTION = "subscription" ATTR_BROWSER = "browser" @@ -84,6 +78,9 @@ ATTR_TTL = "ttl" DEFAULT_TTL = 86400 +DEFAULT_BADGE = "/static/images/notification-badge.png" +DEFAULT_ICON = "/static/icons/favicon-192x192.png" + ATTR_JWT = "jwt" WS_TYPE_APPKEY = "notify/html5/appkey" @@ -159,6 +156,29 @@ ) +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, @@ -166,17 +186,7 @@ async def async_get_service( ) -> HTML5NotificationService | None: """Get the HTML5 push notification service.""" if config: - existing_config_entry = hass.config_entries.async_entries(DOMAIN) - if existing_config_entry: - async_create_html5_issue(hass, True) - return None - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) return None - if discovery_info is None: return None @@ -184,11 +194,14 @@ async def async_get_service( registrations = await hass.async_add_executor_job(_load_config, json_path) - vapid_pub_key = discovery_info[ATTR_VAPID_PUB_KEY] - vapid_prv_key = discovery_info[ATTR_VAPID_PRV_KEY] - vapid_email = discovery_info[ATTR_VAPID_EMAIL] + vapid_pub_key: str = discovery_info[ATTR_VAPID_PUB_KEY] + vapid_prv_key: str = discovery_info[ATTR_VAPID_PRV_KEY] + vapid_email: str = discovery_info[ATTR_VAPID_EMAIL] - def websocket_appkey(_hass, connection, msg): + @callback + def websocket_appkey( + _hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] + ) -> None: connection.send_message(websocket_api.result_message(msg["id"], vapid_pub_key)) websocket_api.async_register_command( @@ -198,15 +211,16 @@ def websocket_appkey(_hass, connection, msg): hass.http.register_view(HTML5PushRegistrationView(registrations, json_path)) hass.http.register_view(HTML5PushCallbackView(registrations)) + session = async_get_clientsession(hass) return HTML5NotificationService( - hass, vapid_prv_key, vapid_email, registrations, json_path + hass, session, vapid_prv_key, vapid_email, registrations, json_path ) -def _load_config(filename: str) -> JsonObjectType: +def _load_config(filename: str) -> dict[str, Registration]: """Load configuration.""" with suppress(HomeAssistantError): - return load_json_object(filename) + return cast(dict[str, Registration], load_json_object(filename)) return {} @@ -216,19 +230,20 @@ class HTML5PushRegistrationView(HomeAssistantView): url = "/api/notify.html5" name = "api:notify.html5" - def __init__(self, registrations, json_path): + def __init__(self, registrations: dict[str, Registration], json_path: str) -> None: """Init HTML5PushRegistrationView.""" self.registrations = registrations self.json_path = json_path - async def post(self, request): + async def post(self, request: web.Request) -> web.Response: """Accept the POST request for push registrations from a browser.""" + try: - data = await request.json() + data: Registration = await request.json() except ValueError: return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) try: - data = REGISTER_SCHEMA(data) + data = cast(Registration, REGISTER_SCHEMA(data)) except vol.Invalid as ex: return self.json_message(humanize_error(data, ex), HTTPStatus.BAD_REQUEST) @@ -257,28 +272,32 @@ async def post(self, request): "Error saving registration.", HTTPStatus.INTERNAL_SERVER_ERROR ) - def find_registration_name(self, data, suggested=None): + def find_registration_name( + self, + data: Registration, + suggested: str | None = None, + ): """Find a registration name matching data or generate a unique one.""" - endpoint = data.get(ATTR_SUBSCRIPTION).get(ATTR_ENDPOINT) + endpoint = data["subscription"]["endpoint"] for key, registration in self.registrations.items(): - subscription = registration.get(ATTR_SUBSCRIPTION) + subscription = registration["subscription"] if subscription.get(ATTR_ENDPOINT) == endpoint: return key return ensure_unique_string(suggested or "unnamed device", self.registrations) - async def delete(self, request): + async def delete(self, request: web.Request): """Delete a registration.""" try: - data = await request.json() + data: dict[str, Any] = await request.json() except ValueError: return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) - subscription = data.get(ATTR_SUBSCRIPTION) + subscription: dict[str, Any] = data[ATTR_SUBSCRIPTION] found = None for key, registration in self.registrations.items(): - if registration.get(ATTR_SUBSCRIPTION) == subscription: + if registration["subscription"] == subscription: found = key break @@ -310,11 +329,11 @@ class HTML5PushCallbackView(HomeAssistantView): url = "/api/notify.html5/callback" name = "api:notify.html5/callback" - def __init__(self, registrations): + def __init__(self, registrations: dict[str, Registration]) -> None: """Init HTML5PushCallbackView.""" self.registrations = registrations - def decode_jwt(self, token): + def decode_jwt(self, token: str) -> web.Response | dict[str, Any]: """Find the registration that signed this JWT and return it.""" # 1. Check claims w/o verifying to see if a target is in there. @@ -322,12 +341,12 @@ def decode_jwt(self, token): # 2a. If decode is successful, return the payload. # 2b. If decode is unsuccessful, return a 401. - target_check = jwt.decode( + target_check: dict[str, Any] = jwt.decode( token, algorithms=["ES256", "HS256"], options={"verify_signature": False} ) if target_check.get(ATTR_TARGET) in self.registrations: possible_target = self.registrations[target_check[ATTR_TARGET]] - key = possible_target[ATTR_SUBSCRIPTION][ATTR_KEYS][ATTR_AUTH] + key = possible_target["subscription"]["keys"]["auth"] with suppress(jwt.exceptions.DecodeError): return jwt.decode(token, key, algorithms=["ES256", "HS256"]) @@ -337,7 +356,9 @@ def decode_jwt(self, token): # The following is based on code from Auth0 # https://auth0.com/docs/quickstart/backend/python - def check_authorization_header(self, request): + def check_authorization_header( + self, request: web.Request + ) -> web.Response | dict[str, Any]: """Check the authorization header.""" if not (auth := request.headers.get(AUTHORIZATION)): return self.json_message( @@ -366,18 +387,18 @@ def check_authorization_header(self, request): ) return payload - async def post(self, request): + async def post(self, request: web.Request) -> web.Response: """Accept the POST request for push registrations event callback.""" auth_check = self.check_authorization_header(request) if not isinstance(auth_check, dict): return auth_check try: - data = await request.json() + data: dict[str, str] = await request.json() except ValueError: return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) - event_payload = { + event_payload: dict[str, Any] = { ATTR_TAG: data.get(ATTR_TAG), ATTR_TYPE: data[ATTR_TYPE], ATTR_TARGET: auth_check[ATTR_TARGET], @@ -405,8 +426,17 @@ async def post(self, request): class HTML5NotificationService(BaseNotificationService): """Implement the notification service for HTML5.""" - def __init__(self, hass, vapid_prv, vapid_email, registrations, json_path): + def __init__( + self, + hass: HomeAssistant, + session: ClientSession, + vapid_prv: str, + vapid_email: str, + registrations: dict[str, Registration], + json_path: str, + ) -> None: """Initialize the service.""" + self.session = session self._vapid_prv = vapid_prv self._vapid_email = vapid_email self.registrations = registrations @@ -414,7 +444,7 @@ def __init__(self, hass, vapid_prv, vapid_email, registrations, json_path): async def async_dismiss_message(service: ServiceCall) -> None: """Handle dismissing notification message service calls.""" - kwargs = {} + kwargs: dict[str, Any] = {} if self.targets is not None: kwargs[ATTR_TARGET] = self.targets @@ -433,42 +463,38 @@ async def async_dismiss_message(service: ServiceCall) -> None: ) @property - def targets(self): + def targets(self) -> dict[str, str]: """Return a dictionary of registered targets.""" return {registration: registration for registration in self.registrations} - def dismiss(self, **kwargs): - """Dismisses a notification.""" - data = kwargs.get(ATTR_DATA) - tag = data.get(ATTR_TAG) if data else "" - payload = {ATTR_TAG: tag, ATTR_DISMISS: True, ATTR_DATA: {}} - - self._push_message(payload, **kwargs) - - async def async_dismiss(self, **kwargs): + async def async_dismiss(self, **kwargs: Any) -> None: """Dismisses a notification. This method must be run in the event loop. """ - await self.hass.async_add_executor_job(partial(self.dismiss, **kwargs)) + 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: {}} - def send_message(self, message: str = "", **kwargs: Any) -> None: + await self._push_message(payload, **kwargs) + + async def async_send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a user.""" tag = str(uuid.uuid4()) - payload = { - "badge": "/static/images/notification-badge.png", + payload: dict[str, Any] = { + "badge": DEFAULT_BADGE, "body": message, ATTR_DATA: {}, - "icon": "/static/icons/favicon-192x192.png", + "icon": DEFAULT_ICON, ATTR_TAG: tag, ATTR_TITLE: kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT), } - - if data := kwargs.get(ATTR_DATA): + data: dict[str, Any] | None = kwargs.get(ATTR_DATA) + if data: # Pick out fields that should go into the notification directly vs # into the notification data dictionary. - data_tmp = {} + data_tmp: dict[str, Any] = {} for key, val in data.items(): if key in HTML5_SHOWNOTIFICATION_PARAMETERS: @@ -484,14 +510,14 @@ def send_message(self, message: str = "", **kwargs: Any) -> None: ): payload[ATTR_DATA][ATTR_URL] = URL_ROOT - self._push_message(payload, **kwargs) + await self._push_message(payload, **kwargs) - def _push_message(self, payload, **kwargs): + async def _push_message(self, payload: dict[str, Any], **kwargs: Any) -> None: """Send the message.""" timestamp = int(time.time()) ttl = int(kwargs.get(ATTR_TTL, DEFAULT_TTL)) - priority = kwargs.get(ATTR_PRIORITY, DEFAULT_PRIORITY) + priority: str = kwargs.get(ATTR_PRIORITY, DEFAULT_PRIORITY) if priority not in ["normal", "high"]: priority = DEFAULT_PRIORITY payload["timestamp"] = timestamp * 1000 # Javascript ms since epoch @@ -502,22 +528,25 @@ def _push_message(self, payload, **kwargs): for target in list(targets): info = self.registrations.get(target) try: - info = REGISTER_SCHEMA(info) + info = cast(Registration, REGISTER_SCHEMA(info)) except vol.Invalid: _LOGGER.error( "%s is not a valid HTML5 push notification target", target ) continue - subscription = info[ATTR_SUBSCRIPTION] + subscription = info["subscription"] payload[ATTR_DATA][ATTR_JWT] = add_jwt( timestamp, target, payload[ATTR_TAG], - subscription[ATTR_KEYS][ATTR_AUTH], + subscription["keys"]["auth"], ) - webpusher = WebPusher(info[ATTR_SUBSCRIPTION]) - endpoint = urlparse(subscription[ATTR_ENDPOINT]) + webpusher = WebPusher( + cast(dict[str, Any], info["subscription"]), aiohttp_session=self.session + ) + + endpoint = urlparse(subscription["endpoint"]) vapid_claims = { "sub": f"mailto:{self._vapid_email}", "aud": f"{endpoint.scheme}://{endpoint.netloc}", @@ -525,29 +554,35 @@ def _push_message(self, payload, **kwargs): } vapid_headers = Vapid.from_string(self._vapid_prv).sign(vapid_claims) vapid_headers.update({"urgency": priority, "priority": priority}) - response = webpusher.send( + + response = await webpusher.send_async( data=json.dumps(payload), headers=vapid_headers, ttl=ttl ) - if response.status_code == 410: + if TYPE_CHECKING: + assert not isinstance(response, str) + + if response.status == HTTPStatus.GONE: _LOGGER.info("Notification channel has expired") reg = self.registrations.pop(target) try: - save_json(self.registrations_json_path, self.registrations) + await self.hass.async_add_executor_job( + save_json, self.registrations_json_path, self.registrations + ) except HomeAssistantError: self.registrations[target] = reg _LOGGER.error("Error saving registration") else: _LOGGER.info("Configuration saved") - elif response.status_code > 399: + elif response.status >= HTTPStatus.BAD_REQUEST: _LOGGER.error( "There was an issue sending the notification %s: %s", - response.status_code, - response.text, + response.status, + await response.text(), ) -def add_jwt(timestamp, target, tag, jwt_secret): +def add_jwt(timestamp: int, target: str, tag: str, jwt_secret: str) -> str: """Create JWT json to put into payload.""" jwt_exp = datetime.fromtimestamp(timestamp) + timedelta(days=JWT_VALID_DAYS) @@ -559,3 +594,128 @@ def add_jwt(timestamp, target, tag, jwt_secret): ATTR_TAG: tag, } return jwt.encode(jwt_claims, jwt_secret) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the notification 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( + HTML5NotifyEntity(config_entry, target, registrations, session, json_path) + for target in registrations + ) + + +class HTML5NotifyEntity(NotifyEntity): + """Representation of a notification entity.""" + + _attr_has_entity_name = True + _attr_name = None + + _attr_supported_features = NotifyEntityFeature.TITLE + + 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}_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}")}, + ) + + 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: 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"], + ) + }, + } + + 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), + } + + try: + response = await webpush_async( + cast(dict[str, Any], self.registration["subscription"]), + json.dumps(payload), + self.config_entry.data[ATTR_VAPID_PRV_KEY], + vapid_claims, + aiohttp_session=self.session, + ) + cast(ClientResponse, response).raise_for_status() + except WebPushException as e: + if cast(ClientResponse, e.response).status == HTTPStatus.GONE: + reg = self.registrations.pop(self.target) + try: + await self.hass.async_add_executor_job( + save_json, self.json_path, self.registrations + ) + except HomeAssistantError: + self.registrations[self.target] = reg + _LOGGER.error("Error saving registration") + + self.async_write_ha_state() + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="channel_expired", + translation_placeholders={"target": self.target}, + ) from e + + _LOGGER.debug("Full exception", exc_info=True) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="request_error", + translation_placeholders={"target": self.target}, + ) from e + except ClientError as e: + _LOGGER.debug("Full exception", exc_info=True) + raise HomeAssistantError( + translation_domain=DOMAIN, + 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/strings.json b/homeassistant/components/html5/strings.json index 283f9277eea714..81964a2af95007 100644 --- a/homeassistant/components/html5/strings.json +++ b/homeassistant/components/html5/strings.json @@ -20,6 +20,17 @@ } } }, + "exceptions": { + "channel_expired": { + "message": "Notification channel for {target} has expired" + }, + "connection_error": { + "message": "Sending notification to {target} failed due to a connection error" + }, + "request_error": { + "message": "Sending notification to {target} failed due to a request error" + } + }, "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.", diff --git a/homeassistant/components/hue/v1/binary_sensor.py b/homeassistant/components/hue/v1/binary_sensor.py index e06d61210b8a57..3654c5c6f1d513 100644 --- a/homeassistant/components/hue/v1/binary_sensor.py +++ b/homeassistant/components/hue/v1/binary_sensor.py @@ -1,5 +1,7 @@ """Hue binary sensor entities.""" +from typing import Any + from aiohue.v1.sensors import TYPE_ZLL_PRESENCE from homeassistant.components.binary_sensor import ( @@ -38,12 +40,12 @@ class HuePresence(GenericZLLSensor, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.MOTION @property - def is_on(self): + def is_on(self) -> bool: """Return true if the binary sensor is on.""" return self.sensor.presence @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the device state attributes.""" attributes = super().extra_state_attributes if "sensitivity" in self.sensor.config: diff --git a/homeassistant/components/hue/v1/light.py b/homeassistant/components/hue/v1/light.py index f5da1ffb762b8f..3afa0945572d09 100644 --- a/homeassistant/components/hue/v1/light.py +++ b/homeassistant/components/hue/v1/light.py @@ -7,6 +7,7 @@ from functools import partial import logging import random +from typing import Any import aiohue @@ -482,7 +483,7 @@ def min_color_temp_kelvin(self) -> int: return color_util.color_temperature_mired_to_kelvin(max_mireds) @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" if self.is_group: return self.light.state["any_on"] @@ -622,7 +623,7 @@ async def async_turn_off(self, **kwargs): await self.coordinator.async_request_refresh() @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the device state attributes.""" if not self.is_group: return {} diff --git a/homeassistant/components/hue/v1/sensor.py b/homeassistant/components/hue/v1/sensor.py index 765808bdf18a05..1f8ad7b1f9a938 100644 --- a/homeassistant/components/hue/v1/sensor.py +++ b/homeassistant/components/hue/v1/sensor.py @@ -1,5 +1,7 @@ """Hue sensor entities.""" +from typing import Any + from aiohue.v1.sensors import ( TYPE_ZLL_LIGHTLEVEL, TYPE_ZLL_ROTARY, @@ -64,7 +66,7 @@ def native_value(self): return round(float(10 ** ((self.sensor.lightlevel - 1) / 10000)), 2) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the device state attributes.""" attributes = super().extra_state_attributes attributes.update( diff --git a/homeassistant/components/hue/v1/sensor_base.py b/homeassistant/components/hue/v1/sensor_base.py index 0ea079992e004c..9cb836386e0893 100644 --- a/homeassistant/components/hue/v1/sensor_base.py +++ b/homeassistant/components/hue/v1/sensor_base.py @@ -206,6 +206,6 @@ class GenericZLLSensor(GenericHueSensor): """Representation of a Hue-brand, physical sensor.""" @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the device state attributes.""" return {"battery_level": self.sensor.battery} diff --git a/homeassistant/components/hue_ble/config_flow.py b/homeassistant/components/hue_ble/config_flow.py index 6d3df824b172ae..fff171609fae3d 100644 --- a/homeassistant/components/hue_ble/config_flow.py +++ b/homeassistant/components/hue_ble/config_flow.py @@ -6,6 +6,7 @@ import logging from typing import Any +from bleak.backends.scanner import AdvertisementData from HueBLE import ConnectionError, HueBleError, HueBleLight, PairingError import voluptuous as vol @@ -26,6 +27,17 @@ _LOGGER = logging.getLogger(__name__) +SERVICE_UUID = SERVICE_DATA_UUID = "0000fe0f-0000-1000-8000-00805f9b34fb" + + +def device_filter(advertisement_data: AdvertisementData) -> bool: + """Return True if the device is supported.""" + return ( + SERVICE_UUID in advertisement_data.service_uuids + and SERVICE_DATA_UUID in advertisement_data.service_data + ) + + async def validate_input(hass: HomeAssistant, address: str) -> Error | None: """Return error if cannot connect and validate.""" @@ -70,28 +82,66 @@ class HueBleConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the config flow.""" + self._discovered_devices: dict[str, bluetooth.BluetoothServiceInfoBleak] = {} self._discovery_info: bluetooth.BluetoothServiceInfoBleak | None = None + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step to pick discovered device.""" + errors: dict[str, str] = {} + + if user_input is not None: + unique_id = dr.format_mac(user_input[CONF_MAC]) + # Don't raise on progress because there may be discovery flows + await self.async_set_unique_id(unique_id, raise_on_progress=False) + # Guard against the user selecting a device which has been configured by + # another flow. + self._abort_if_unique_id_configured() + self._discovery_info = self._discovered_devices[user_input[CONF_MAC]] + return await self.async_step_confirm() + + current_addresses = self._async_current_ids(include_ignore=False) + for discovery in bluetooth.async_discovered_service_info(self.hass): + if ( + discovery.address in current_addresses + or discovery.address in self._discovered_devices + or not device_filter(discovery.advertisement) + ): + continue + self._discovered_devices[discovery.address] = discovery + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + data_schema = vol.Schema( + { + vol.Required(CONF_MAC): vol.In( + { + service_info.address: ( + f"{service_info.name} ({service_info.address})" + ) + for service_info in self._discovered_devices.values() + } + ), + } + ) + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + ) + async def async_step_bluetooth( self, discovery_info: bluetooth.BluetoothServiceInfoBleak ) -> ConfigFlowResult: """Handle a flow initialized by the home assistant scanner.""" _LOGGER.debug( - "HA found light %s. Will show in UI but not auto connect", + "HA found light %s. Use user flow to show in UI and connect", discovery_info.name, ) - - unique_id = dr.format_mac(discovery_info.address) - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured() - - name = f"{discovery_info.name} ({discovery_info.address})" - self.context.update({"title_placeholders": {CONF_NAME: name}}) - - self._discovery_info = discovery_info - - return await self.async_step_confirm() + return self.async_abort(reason="discovery_unsupported") async def async_step_confirm( self, user_input: dict[str, Any] | None = None @@ -103,7 +153,10 @@ async def async_step_confirm( if user_input is not None: unique_id = dr.format_mac(self._discovery_info.address) - await self.async_set_unique_id(unique_id) + # Don't raise on progress because there may be discovery flows + await self.async_set_unique_id(unique_id, raise_on_progress=False) + # Guard against the user selecting a device which has been configured by + # another flow. self._abort_if_unique_id_configured() error = await validate_input(self.hass, unique_id) if error: diff --git a/homeassistant/components/hue_ble/strings.json b/homeassistant/components/hue_ble/strings.json index bbae80573f3dcc..610df5f5721549 100644 --- a/homeassistant/components/hue_ble/strings.json +++ b/homeassistant/components/hue_ble/strings.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "not_implemented": "This integration can only be set up via discovery." + "discovery_unsupported": "Discovery flow is not supported by the Hue BLE integration.", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -14,7 +15,16 @@ }, "step": { "confirm": { - "description": "Do you want to set up {name} ({mac})?. Make sure the light is [made discoverable to voice assistants]({url_pairing_mode}) or has been [factory reset]({url_factory_reset})." + "description": "Do you want to set up {name} ({mac})?\nMake sure the light is [made discoverable to voice assistants]({url_pairing_mode}) or has been [factory reset]({url_factory_reset})." + }, + "user": { + "data": { + "mac": "[%key:common::config_flow::data::device%]" + }, + "data_description": { + "mac": "Select the Hue device you want to set up" + }, + "description": "[%key:component::bluetooth::config::step::user::description%]" } } } diff --git a/homeassistant/components/humidifier/condition.py b/homeassistant/components/humidifier/condition.py index 77c108128a22b7..f29100ae40256d 100644 --- a/homeassistant/components/humidifier/condition.py +++ b/homeassistant/components/humidifier/condition.py @@ -2,22 +2,19 @@ from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers.condition import ( - Condition, - make_entity_state_attribute_condition, - make_entity_state_condition, -) +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.condition import Condition, make_entity_state_condition from .const import ATTR_ACTION, DOMAIN, HumidifierAction CONDITIONS: dict[str, type[Condition]] = { "is_off": make_entity_state_condition(DOMAIN, STATE_OFF), "is_on": make_entity_state_condition(DOMAIN, STATE_ON), - "is_drying": make_entity_state_attribute_condition( - DOMAIN, ATTR_ACTION, HumidifierAction.DRYING + "is_drying": make_entity_state_condition( + {DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.DRYING ), - "is_humidifying": make_entity_state_attribute_condition( - DOMAIN, ATTR_ACTION, HumidifierAction.HUMIDIFYING + "is_humidifying": make_entity_state_condition( + {DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.HUMIDIFYING ), } diff --git a/homeassistant/components/humidifier/icons.json b/homeassistant/components/humidifier/icons.json index 589759f7123560..1154bfa5e195aa 100644 --- a/homeassistant/components/humidifier/icons.json +++ b/homeassistant/components/humidifier/icons.json @@ -64,12 +64,6 @@ } }, "triggers": { - "current_humidity_changed": { - "trigger": "mdi:water-percent" - }, - "current_humidity_crossed_threshold": { - "trigger": "mdi:water-percent" - }, "started_drying": { "trigger": "mdi:arrow-down-bold" }, diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index 9182354de9ad94..df2e39286e329b 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -199,42 +199,6 @@ }, "title": "Humidifier", "triggers": { - "current_humidity_changed": { - "description": "Triggers after the humidity measured by one or more humidifiers changes.", - "fields": { - "above": { - "description": "Trigger when the humidity is above this value.", - "name": "Above" - }, - "below": { - "description": "Trigger when the humidity is below this value.", - "name": "Below" - } - }, - "name": "Humidifier current humidity changed" - }, - "current_humidity_crossed_threshold": { - "description": "Triggers after the humidity measured by one or more humidifiers crosses a threshold.", - "fields": { - "behavior": { - "description": "[%key:component::climate::common::trigger_behavior_description%]", - "name": "[%key:component::climate::common::trigger_behavior_name%]" - }, - "lower_limit": { - "description": "Lower threshold limit.", - "name": "Lower threshold" - }, - "threshold_type": { - "description": "Type of threshold crossing to trigger on.", - "name": "Threshold type" - }, - "upper_limit": { - "description": "Upper threshold limit.", - "name": "Upper threshold" - } - }, - "name": "Humidifier current humidity crossed threshold" - }, "started_drying": { "description": "Triggers after one or more humidifiers start drying.", "fields": { diff --git a/homeassistant/components/humidifier/trigger.py b/homeassistant/components/humidifier/trigger.py index bb720e08e06897..44179856f2758e 100644 --- a/homeassistant/components/humidifier/trigger.py +++ b/homeassistant/components/humidifier/trigger.py @@ -2,28 +2,17 @@ from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers.trigger import ( - Trigger, - make_entity_numerical_state_attribute_changed_trigger, - make_entity_numerical_state_attribute_crossed_threshold_trigger, - make_entity_target_state_attribute_trigger, - make_entity_target_state_trigger, -) +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger -from .const import ATTR_ACTION, ATTR_CURRENT_HUMIDITY, DOMAIN, HumidifierAction +from .const import ATTR_ACTION, DOMAIN, HumidifierAction TRIGGERS: dict[str, type[Trigger]] = { - "current_humidity_changed": make_entity_numerical_state_attribute_changed_trigger( - DOMAIN, ATTR_CURRENT_HUMIDITY + "started_drying": make_entity_target_state_trigger( + {DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.DRYING ), - "current_humidity_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger( - DOMAIN, ATTR_CURRENT_HUMIDITY - ), - "started_drying": make_entity_target_state_attribute_trigger( - DOMAIN, ATTR_ACTION, HumidifierAction.DRYING - ), - "started_humidifying": make_entity_target_state_attribute_trigger( - DOMAIN, ATTR_ACTION, HumidifierAction.HUMIDIFYING + "started_humidifying": make_entity_target_state_trigger( + {DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.HUMIDIFYING ), "turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF), "turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON), diff --git a/homeassistant/components/humidifier/triggers.yaml b/homeassistant/components/humidifier/triggers.yaml index 23e8986ba6b5ba..5773f999c88e40 100644 --- a/homeassistant/components/humidifier/triggers.yaml +++ b/homeassistant/components/humidifier/triggers.yaml @@ -1,9 +1,9 @@ .trigger_common: &trigger_common - target: &trigger_humidifier_target + target: entity: domain: humidifier fields: - behavior: &trigger_behavior + behavior: required: true default: any selector: @@ -14,52 +14,7 @@ - last - any -.number_or_entity: &number_or_entity - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - entity: - selector: - entity: - filter: - domain: - - input_number - - number - - sensor - translation_key: number_or_entity - -.trigger_threshold_type: &trigger_threshold_type - required: true - default: above - selector: - select: - options: - - above - - below - - between - - outside - translation_key: trigger_threshold_type - started_drying: *trigger_common started_humidifying: *trigger_common turned_on: *trigger_common turned_off: *trigger_common - -current_humidity_changed: - target: *trigger_humidifier_target - fields: - above: *number_or_entity - below: *number_or_entity - -current_humidity_crossed_threshold: - target: *trigger_humidifier_target - fields: - behavior: *trigger_behavior - threshold_type: *trigger_threshold_type - lower_limit: *number_or_entity - upper_limit: *number_or_entity diff --git a/homeassistant/components/humidity/__init__.py b/homeassistant/components/humidity/__init__.py new file mode 100644 index 00000000000000..2c84f69089fc53 --- /dev/null +++ b/homeassistant/components/humidity/__init__.py @@ -0,0 +1,17 @@ +"""Integration for humidity triggers.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +DOMAIN = "humidity" +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/humidity/icons.json b/homeassistant/components/humidity/icons.json new file mode 100644 index 00000000000000..6b3c862c663647 --- /dev/null +++ b/homeassistant/components/humidity/icons.json @@ -0,0 +1,10 @@ +{ + "triggers": { + "changed": { + "trigger": "mdi:water-percent" + }, + "crossed_threshold": { + "trigger": "mdi:water-percent" + } + } +} diff --git a/homeassistant/components/humidity/manifest.json b/homeassistant/components/humidity/manifest.json new file mode 100644 index 00000000000000..857036a96db7bf --- /dev/null +++ b/homeassistant/components/humidity/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "humidity", + "name": "Humidity", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/humidity", + "integration_type": "system", + "quality_scale": "internal" +} diff --git a/homeassistant/components/humidity/strings.json b/homeassistant/components/humidity/strings.json new file mode 100644 index 00000000000000..d93d3ce7308a0e --- /dev/null +++ b/homeassistant/components/humidity/strings.json @@ -0,0 +1,68 @@ +{ + "common": { + "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", + "trigger_behavior_name": "Behavior" + }, + "selector": { + "number_or_entity": { + "choices": { + "entity": "Entity", + "number": "Number" + } + }, + "trigger_behavior": { + "options": { + "any": "Any", + "first": "First", + "last": "Last" + } + }, + "trigger_threshold_type": { + "options": { + "above": "Above", + "below": "Below", + "between": "Between", + "outside": "Outside" + } + } + }, + "title": "Humidity", + "triggers": { + "changed": { + "description": "Triggers when the relative humidity changes.", + "fields": { + "above": { + "description": "Only trigger when relative humidity is above this value.", + "name": "Above" + }, + "below": { + "description": "Only trigger when relative humidity is below this value.", + "name": "Below" + } + }, + "name": "Relative humidity changed" + }, + "crossed_threshold": { + "description": "Triggers when the relative humidity crosses a threshold.", + "fields": { + "behavior": { + "description": "[%key:component::humidity::common::trigger_behavior_description%]", + "name": "[%key:component::humidity::common::trigger_behavior_name%]" + }, + "lower_limit": { + "description": "The lower limit of the threshold.", + "name": "Lower limit" + }, + "threshold_type": { + "description": "The type of threshold to use.", + "name": "Threshold type" + }, + "upper_limit": { + "description": "The upper limit of the threshold.", + "name": "Upper limit" + } + }, + "name": "Relative humidity crossed threshold" + } + } +} diff --git a/homeassistant/components/humidity/trigger.py b/homeassistant/components/humidity/trigger.py new file mode 100644 index 00000000000000..b1845247622cee --- /dev/null +++ b/homeassistant/components/humidity/trigger.py @@ -0,0 +1,51 @@ +"""Provides triggers for humidity.""" + +from __future__ import annotations + +from homeassistant.components.climate import ( + ATTR_CURRENT_HUMIDITY as CLIMATE_ATTR_CURRENT_HUMIDITY, + DOMAIN as CLIMATE_DOMAIN, +) +from homeassistant.components.humidifier import ( + ATTR_CURRENT_HUMIDITY as HUMIDIFIER_ATTR_CURRENT_HUMIDITY, + DOMAIN as HUMIDIFIER_DOMAIN, +) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass +from homeassistant.components.weather import ( + ATTR_WEATHER_HUMIDITY, + DOMAIN as WEATHER_DOMAIN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.automation import NumericalDomainSpec +from homeassistant.helpers.trigger import ( + Trigger, + make_entity_numerical_state_changed_trigger, + make_entity_numerical_state_crossed_threshold_trigger, +) + +HUMIDITY_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = { + CLIMATE_DOMAIN: NumericalDomainSpec( + value_source=CLIMATE_ATTR_CURRENT_HUMIDITY, + ), + HUMIDIFIER_DOMAIN: NumericalDomainSpec( + value_source=HUMIDIFIER_ATTR_CURRENT_HUMIDITY, + ), + SENSOR_DOMAIN: NumericalDomainSpec( + device_class=SensorDeviceClass.HUMIDITY, + ), + WEATHER_DOMAIN: NumericalDomainSpec( + value_source=ATTR_WEATHER_HUMIDITY, + ), +} + +TRIGGERS: dict[str, type[Trigger]] = { + "changed": make_entity_numerical_state_changed_trigger(HUMIDITY_DOMAIN_SPECS), + "crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( + HUMIDITY_DOMAIN_SPECS + ), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for humidity.""" + return TRIGGERS diff --git a/homeassistant/components/humidity/triggers.yaml b/homeassistant/components/humidity/triggers.yaml new file mode 100644 index 00000000000000..9327bdd9c25692 --- /dev/null +++ b/homeassistant/components/humidity/triggers.yaml @@ -0,0 +1,65 @@ +.trigger_common_fields: + behavior: &trigger_behavior + required: true + default: any + selector: + select: + translation_key: trigger_behavior + options: + - first + - last + - any + +.number_or_entity: &number_or_entity + required: false + selector: + choose: + choices: + number: + selector: + number: + mode: box + unit_of_measurement: "%" + entity: + selector: + entity: + filter: + domain: + - input_number + - number + - sensor + translation_key: number_or_entity + +.trigger_threshold_type: &trigger_threshold_type + required: true + default: above + selector: + select: + options: + - above + - below + - between + - outside + translation_key: trigger_threshold_type + +.trigger_target: &trigger_target + entity: + - domain: sensor + device_class: humidity + - domain: climate + - domain: humidifier + - domain: weather + +changed: + target: *trigger_target + fields: + above: *number_or_entity + below: *number_or_entity + +crossed_threshold: + target: *trigger_target + fields: + behavior: *trigger_behavior + threshold_type: *trigger_threshold_type + lower_limit: *number_or_entity + upper_limit: *number_or_entity diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index 6a9c9a28c1a533..b78d0be08653eb 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -901,7 +901,9 @@ def open_position(self) -> ShadePosition: ) -class PowerViewShadeDualOverlappedCombinedTilt(PowerViewShadeDualOverlappedCombined): +class PowerViewShadeDualOverlappedCombinedTilt( + PowerViewShadeDualOverlappedCombined, PowerViewShadeWithTiltBase +): """Represent a shade that has a front sheer and rear opaque panel. This equates to two shades being controlled by one motor. @@ -915,26 +917,6 @@ class PowerViewShadeDualOverlappedCombinedTilt(PowerViewShadeDualOverlappedCombi Type 10 - Duolite with 180° Tilt """ - # type - def __init__( - self, - coordinator: PowerviewShadeUpdateCoordinator, - device_info: PowerviewDeviceInfo, - room_name: str, - shade: BaseShade, - name: str, - ) -> None: - """Initialize the shade.""" - super().__init__(coordinator, device_info, room_name, shade, name) - self._attr_supported_features |= ( - CoverEntityFeature.OPEN_TILT - | CoverEntityFeature.CLOSE_TILT - | CoverEntityFeature.SET_TILT_POSITION - ) - if self._shade.is_supported(MOTION_STOP): - self._attr_supported_features |= CoverEntityFeature.STOP_TILT - self._max_tilt = self._shade.shade_limits.tilt_max - @property def transition_steps(self) -> int: """Return the steps to make a move.""" @@ -949,26 +931,6 @@ def transition_steps(self) -> int: tilt = self.positions.tilt return ceil(primary + secondary + tilt) - @callback - def _get_shade_tilt(self, target_hass_tilt_position: int) -> ShadePosition: - """Return a ShadePosition.""" - return ShadePosition( - tilt=target_hass_tilt_position, - velocity=self.positions.velocity, - ) - - @property - def open_tilt_position(self) -> ShadePosition: - """Return the open tilt position and required additional positions.""" - return replace(self._shade.open_position_tilt, velocity=self.positions.velocity) - - @property - def close_tilt_position(self) -> ShadePosition: - """Return the open tilt position and required additional positions.""" - return replace( - self._shade.close_position_tilt, velocity=self.positions.velocity - ) - TYPE_TO_CLASSES = { 0: (PowerViewShade,), diff --git a/homeassistant/components/hunterdouglas_powerview/diagnostics.py b/homeassistant/components/hunterdouglas_powerview/diagnostics.py index 7d6908f1936617..d7d88a849b46ba 100644 --- a/homeassistant/components/hunterdouglas_powerview/diagnostics.py +++ b/homeassistant/components/hunterdouglas_powerview/diagnostics.py @@ -7,7 +7,7 @@ import attr -from homeassistant.components.diagnostics import async_redact_data +from homeassistant.components.diagnostics import async_redact_data, entity_entry_as_dict from homeassistant.const import ATTR_CONFIGURATION_URL, CONF_HOST from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -94,7 +94,7 @@ def _async_device_as_dict(hass: HomeAssistant, device: DeviceEntry) -> dict[str, state_dict = dict(state.as_dict()) state_dict.pop("context", None) - entity = attr.asdict(entity_entry) + entity = entity_entry_as_dict(entity_entry) entity["state"] = state_dict entities.append(entity) diff --git a/homeassistant/components/husqvarna_automower/services.yaml b/homeassistant/components/husqvarna_automower/services.yaml index 29c89360d1ef84..89f879a386c8e5 100644 --- a/homeassistant/components/husqvarna_automower/services.yaml +++ b/homeassistant/components/husqvarna_automower/services.yaml @@ -10,6 +10,7 @@ override_schedule: selector: duration: enable_day: true + enable_second: false override_mode: required: true example: "mow" @@ -32,6 +33,7 @@ override_schedule_work_area: selector: duration: enable_day: true + enable_second: false work_area_id: required: true example: "123" diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 39aaebab63429c..912c6c3b51a7b5 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -511,7 +511,7 @@ "description": "Lets the mower either mow or park for a given duration, overriding all schedules.", "fields": { "duration": { - "description": "Minimum: 1 minute, maximum: 42 days, seconds will be ignored.", + "description": "Minimum: 1 minute, maximum: 42 days.", "name": "Duration" }, "override_mode": { diff --git a/homeassistant/components/husqvarna_automower_ble/config_flow.py b/homeassistant/components/husqvarna_automower_ble/config_flow.py index c1002a9b0e48bf..d36b89f2d13156 100644 --- a/homeassistant/components/husqvarna_automower_ble/config_flow.py +++ b/homeassistant/components/husqvarna_automower_ble/config_flow.py @@ -58,7 +58,7 @@ def _is_supported(discovery_info: BluetoothServiceInfo): # 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, None): + if product_type not in (ProductType.MOWER, ProductType.UNKNOWN): LOGGER.debug("Unsupported device: %s (%s)", manufacturer_data, discovery_info) return False diff --git a/homeassistant/components/husqvarna_automower_ble/manifest.json b/homeassistant/components/husqvarna_automower_ble/manifest.json index a1ce1e118f4f94..3c9fb7d57c87af 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==1.6.0"] + "requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.1.0"] } diff --git a/homeassistant/components/huum/binary_sensor.py b/homeassistant/components/huum/binary_sensor.py index 7bc03e9fe9476f..cb5da1879c7583 100644 --- a/homeassistant/components/huum/binary_sensor.py +++ b/homeassistant/components/huum/binary_sensor.py @@ -12,6 +12,8 @@ from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator from .entity import HuumBaseEntity +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py index c520880e6919c5..fd7786f5e55eae 100644 --- a/homeassistant/components/huum/climate.py +++ b/homeassistant/components/huum/climate.py @@ -24,6 +24,8 @@ _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 1 + async def async_setup_entry( hass: HomeAssistant, @@ -70,13 +72,6 @@ def hvac_mode(self) -> HVACMode: return HVACMode.HEAT return HVACMode.OFF - @property - def icon(self) -> str: - """Return nice icon for heater.""" - if self.hvac_mode == HVACMode.HEAT: - return "mdi:radiator" - return "mdi:radiator-off" - @property def current_temperature(self) -> int | None: """Return the current temperature.""" diff --git a/homeassistant/components/huum/config_flow.py b/homeassistant/components/huum/config_flow.py index 3654a17c8f4135..c5cdc18107a1d5 100644 --- a/homeassistant/components/huum/config_flow.py +++ b/homeassistant/components/huum/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -36,6 +37,7 @@ async def async_step_user( """Handle the initial step.""" errors = {} if user_input is not None: + self._async_abort_entries_match({CONF_USERNAME: user_input[CONF_USERNAME]}) try: huum = Huum( user_input[CONF_USERNAME], @@ -44,16 +46,11 @@ async def async_step_user( ) await huum.status() except Forbidden, NotAuthenticated: - # Most likely Forbidden as that is what is returned from `.status()` with bad creds - _LOGGER.error("Could not log in to Huum with given credentials") errors["base"] = "invalid_auth" except Exception: _LOGGER.exception("Unknown error") errors["base"] = "unknown" else: - self._async_abort_entries_match( - {CONF_USERNAME: user_input[CONF_USERNAME]} - ) return self.async_create_entry( title=user_input[CONF_USERNAME], data=user_input ) @@ -61,3 +58,48 @@ 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 reauthentication upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauthentication dialog.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + + if user_input is not None: + huum = Huum( + reauth_entry.data[CONF_USERNAME], + user_input[CONF_PASSWORD], + session=async_get_clientsession(self.hass), + ) + try: + 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( + reauth_entry, + data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]}, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + } + ), + description_placeholders={ + "username": reauth_entry.data[CONF_USERNAME], + }, + errors=errors, + ) diff --git a/homeassistant/components/huum/coordinator.py b/homeassistant/components/huum/coordinator.py index 6580ca99da7345..532e78a81759aa 100644 --- a/homeassistant/components/huum/coordinator.py +++ b/homeassistant/components/huum/coordinator.py @@ -12,8 +12,9 @@ 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.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN @@ -54,7 +55,6 @@ async def _async_update_data(self) -> HuumStatusResponse: try: return await self.huum.status() except (Forbidden, NotAuthenticated) as err: - _LOGGER.error("Could not log in to Huum with given credentials") - raise UpdateFailed( + raise ConfigEntryAuthFailed( "Could not log in to Huum with given credentials" ) from err diff --git a/homeassistant/components/huum/light.py b/homeassistant/components/huum/light.py index 9d3ec54101df1a..5881d2d08b962b 100644 --- a/homeassistant/components/huum/light.py +++ b/homeassistant/components/huum/light.py @@ -15,6 +15,8 @@ _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 1 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/huum/manifest.json b/homeassistant/components/huum/manifest.json index 5620932bf888a7..5012e917792321 100644 --- a/homeassistant/components/huum/manifest.json +++ b/homeassistant/components/huum/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/huum", "integration_type": "device", "iot_class": "cloud_polling", + "quality_scale": "bronze", "requirements": ["huum==0.8.1"] } diff --git a/homeassistant/components/huum/number.py b/homeassistant/components/huum/number.py index 4c3a6ff0cae353..161fd4f5f36b53 100644 --- a/homeassistant/components/huum/number.py +++ b/homeassistant/components/huum/number.py @@ -16,6 +16,8 @@ _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 1 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/huum/quality_scale.yaml b/homeassistant/components/huum/quality_scale.yaml new file mode 100644 index 00000000000000..d2d75fd86c59c2 --- /dev/null +++ b/homeassistant/components/huum/quality_scale.yaml @@ -0,0 +1,81 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not register custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration has no options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: + status: todo + comment: | + PLANNED: Use freezer-based time advancement instead of directly calling async_refresh(). + + # Gold + devices: done + diagnostics: todo + discovery: todo + discovery-update-info: todo + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: + status: exempt + comment: Single device per account, no dynamic devices. + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: All entities are core functionality. + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: Integration has no repair scenarios. + stale-devices: + status: exempt + comment: Single device per config entry. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/huum/sensor.py b/homeassistant/components/huum/sensor.py index 9629fcfdf88583..0ceed8510d0d83 100644 --- a/homeassistant/components/huum/sensor.py +++ b/homeassistant/components/huum/sensor.py @@ -14,6 +14,8 @@ from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator from .entity import HuumBaseEntity +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/huum/strings.json b/homeassistant/components/huum/strings.json index 41e1bb019a1799..9ad89b5daaf443 100644 --- a/homeassistant/components/huum/strings.json +++ b/homeassistant/components/huum/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%]", @@ -9,11 +10,25 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::huum::config::step::user::data_description::password%]" + }, + "description": "The authentication for {username} is no longer valid. Please enter the current password.", + "title": "[%key:common::config_flow::title::reauth%]" + }, "user": { "data": { "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" }, + "data_description": { + "password": "The password used in the Huum mobile app.", + "username": "The username (email) used in the Huum mobile app." + }, "description": "Log in with the same username and password that is used in the Huum mobile app.", "title": "Connect to the Huum" } diff --git a/homeassistant/components/hvv_departures/binary_sensor.py b/homeassistant/components/hvv_departures/binary_sensor.py index 380c207dbb2e8b..6260fd9fef444f 100644 --- a/homeassistant/components/hvv_departures/binary_sensor.py +++ b/homeassistant/components/hvv_departures/binary_sensor.py @@ -154,7 +154,7 @@ def __init__(self, coordinator, idx, config_entry): ) @property - def is_on(self): + def is_on(self) -> bool: """Return entity state.""" return self.coordinator.data[self.idx]["state"] diff --git a/homeassistant/components/hypontech/__init__.py b/homeassistant/components/hypontech/__init__.py new file mode 100644 index 00000000000000..ba0c0e5d459698 --- /dev/null +++ b/homeassistant/components/hypontech/__init__.py @@ -0,0 +1,47 @@ +"""The Hypontech Cloud integration.""" + +from __future__ import annotations + +from hyponcloud import AuthenticationError, HyponCloud, RequestError + +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 .coordinator import HypontechConfigEntry, HypontechDataCoordinator + +_PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: HypontechConfigEntry) -> bool: + """Set up Hypontech Cloud from a config entry.""" + session = async_get_clientsession(hass) + hypontech_cloud = HyponCloud( + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + session, + ) + try: + await hypontech_cloud.connect() + except AuthenticationError as ex: + raise ConfigEntryAuthFailed("Authentication failed for Hypontech Cloud") from ex + except (RequestError, TimeoutError, ConnectionError) as ex: + raise ConfigEntryNotReady("Cannot connect to Hypontech Cloud") from ex + + assert entry.unique_id + coordinator = HypontechDataCoordinator( + hass, entry, hypontech_cloud, entry.unique_id + ) + 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: HypontechConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/hypontech/config_flow.py b/homeassistant/components/hypontech/config_flow.py new file mode 100644 index 00000000000000..a0f233b0039167 --- /dev/null +++ b/homeassistant/components/hypontech/config_flow.py @@ -0,0 +1,76 @@ +"""Config flow for the Hypontech Cloud integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from hyponcloud import AuthenticationError, HyponCloud +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class HypontechConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Hypontech Cloud.""" + + 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: + session = async_get_clientsession(self.hass) + hypon = HyponCloud( + user_input[CONF_USERNAME], user_input[CONF_PASSWORD], session + ) + try: + await hypon.connect() + admin_info = await hypon.get_admin_info() + except AuthenticationError: + errors["base"] = "invalid_auth" + except TimeoutError, ConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(admin_info.id) + if self.source == SOURCE_USER: + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data=user_input, + ) + self._abort_if_unique_id_mismatch(reason="wrong_account") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={ + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + + 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 reauthentication.""" + return await self.async_step_user() diff --git a/homeassistant/components/hypontech/const.py b/homeassistant/components/hypontech/const.py new file mode 100644 index 00000000000000..4f290ee882d460 --- /dev/null +++ b/homeassistant/components/hypontech/const.py @@ -0,0 +1,7 @@ +"""Constants for the Hypontech Cloud integration.""" + +from logging import Logger, getLogger + +DOMAIN = "hypontech" + +LOGGER: Logger = getLogger(__package__) diff --git a/homeassistant/components/hypontech/coordinator.py b/homeassistant/components/hypontech/coordinator.py new file mode 100644 index 00000000000000..b3cae5d6e5de3f --- /dev/null +++ b/homeassistant/components/hypontech/coordinator.py @@ -0,0 +1,62 @@ +"""The coordinator for Hypontech Cloud integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta + +from hyponcloud import HyponCloud, OverviewData, PlantData, RequestError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + + +@dataclass +class HypontechCoordinatorData: + """Store coordinator data.""" + + overview: OverviewData + plants: dict[str, PlantData] + + +type HypontechConfigEntry = ConfigEntry[HypontechDataCoordinator] + + +class HypontechDataCoordinator(DataUpdateCoordinator[HypontechCoordinatorData]): + """Coordinator used for all sensors.""" + + config_entry: HypontechConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: HypontechConfigEntry, + api: HyponCloud, + account_id: str, + ) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name="Hypontech Data", + update_interval=timedelta(seconds=60), + ) + self.api = api + self.account_id = account_id + + async def _async_update_data(self) -> HypontechCoordinatorData: + try: + overview = await self.api.get_overview() + plants = await self.api.get_list() + except RequestError as ex: + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="connection_error" + ) from ex + return HypontechCoordinatorData( + overview=overview, + plants={plant.plant_id: plant for plant in plants}, + ) diff --git a/homeassistant/components/hypontech/entity.py b/homeassistant/components/hypontech/entity.py new file mode 100644 index 00000000000000..a8abb23cf09c8b --- /dev/null +++ b/homeassistant/components/hypontech/entity.py @@ -0,0 +1,53 @@ +"""Base entity for the Hypontech Cloud integration.""" + +from __future__ import annotations + +from hyponcloud import PlantData + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import HypontechDataCoordinator + + +class HypontechEntity(CoordinatorEntity[HypontechDataCoordinator]): + """Base entity for Hypontech Cloud.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: HypontechDataCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.account_id)}, + name="Overview", + manufacturer="Hypontech", + ) + + +class HypontechPlantEntity(CoordinatorEntity[HypontechDataCoordinator]): + """Base entity for Hypontech Cloud plant.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: HypontechDataCoordinator, plant_id: str) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.plant_id = plant_id + plant = coordinator.data.plants[plant_id] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, plant_id)}, + name=plant.plant_name, + manufacturer="Hypontech", + ) + + @property + def plant(self) -> PlantData: + """Return the plant data.""" + return self.coordinator.data.plants[self.plant_id] + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.plant_id in self.coordinator.data.plants diff --git a/homeassistant/components/hypontech/manifest.json b/homeassistant/components/hypontech/manifest.json new file mode 100644 index 00000000000000..0f417f491c1a21 --- /dev/null +++ b/homeassistant/components/hypontech/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "hypontech", + "name": "Hypontech Cloud", + "codeowners": ["@jcisio"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/hypontech", + "integration_type": "hub", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["hyponcloud==0.9.0"] +} diff --git a/homeassistant/components/hypontech/quality_scale.yaml b/homeassistant/components/hypontech/quality_scale.yaml new file mode 100644 index 00000000000000..cd76f521036e3f --- /dev/null +++ b/homeassistant/components/hypontech/quality_scale.yaml @@ -0,0 +1,60 @@ +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: done + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: done + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: done + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/hypontech/sensor.py b/homeassistant/components/hypontech/sensor.py new file mode 100644 index 00000000000000..4552f445543fad --- /dev/null +++ b/homeassistant/components/hypontech/sensor.py @@ -0,0 +1,173 @@ +"""The read-only sensors for Hypontech integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from hyponcloud import OverviewData, PlantData + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfEnergy, UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import HypontechConfigEntry, HypontechDataCoordinator +from .entity import HypontechEntity, HypontechPlantEntity + + +def _power_unit(data: OverviewData | PlantData) -> str: + """Return the unit of measurement for power based on the API unit.""" + return UnitOfPower.KILO_WATT if data.company.upper() == "KW" else UnitOfPower.WATT + + +@dataclass(frozen=True, kw_only=True) +class HypontechSensorDescription(SensorEntityDescription): + """Describes Hypontech overview sensor entity.""" + + value_fn: Callable[[OverviewData], float | None] + unit_fn: Callable[[OverviewData], str] | None = None + + +@dataclass(frozen=True, kw_only=True) +class HypontechPlantSensorDescription(SensorEntityDescription): + """Describes Hypontech plant sensor entity.""" + + value_fn: Callable[[PlantData], float | None] + unit_fn: Callable[[PlantData], str] | None = None + + +OVERVIEW_SENSORS: tuple[HypontechSensorDescription, ...] = ( + HypontechSensorDescription( + key="pv_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda c: c.power, + unit_fn=_power_unit, + ), + HypontechSensorDescription( + key="lifetime_energy", + translation_key="lifetime_energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda c: c.e_total, + ), + HypontechSensorDescription( + key="today_energy", + translation_key="today_energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda c: c.e_today, + ), +) + +PLANT_SENSORS: tuple[HypontechPlantSensorDescription, ...] = ( + HypontechPlantSensorDescription( + key="pv_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda c: c.power, + unit_fn=_power_unit, + ), + HypontechPlantSensorDescription( + key="lifetime_energy", + translation_key="lifetime_energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda c: c.e_total, + ), + HypontechPlantSensorDescription( + key="today_energy", + translation_key="today_energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda c: c.e_today, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HypontechConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + coordinator = config_entry.runtime_data + + entities: list[SensorEntity] = [ + HypontechOverviewSensor(coordinator, desc) for desc in OVERVIEW_SENSORS + ] + + entities.extend( + HypontechPlantSensor(coordinator, plant_id, desc) + for plant_id in coordinator.data.plants + for desc in PLANT_SENSORS + ) + + async_add_entities(entities) + + +class HypontechOverviewSensor(HypontechEntity, SensorEntity): + """Class describing Hypontech overview sensor entities.""" + + entity_description: HypontechSensorDescription + + def __init__( + self, + coordinator: HypontechDataCoordinator, + description: HypontechSensorDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.account_id}_{description.key}" + + @property + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement.""" + if self.entity_description.unit_fn is not None: + return self.entity_description.unit_fn(self.coordinator.data.overview) + return super().native_unit_of_measurement + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data.overview) + + +class HypontechPlantSensor(HypontechPlantEntity, SensorEntity): + """Class describing Hypontech plant sensor entities.""" + + entity_description: HypontechPlantSensorDescription + + def __init__( + self, + coordinator: HypontechDataCoordinator, + plant_id: str, + description: HypontechPlantSensorDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, plant_id) + self.entity_description = description + self._attr_unique_id = f"{plant_id}_{description.key}" + + @property + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement.""" + if self.entity_description.unit_fn is not None: + return self.entity_description.unit_fn(self.plant) + return super().native_unit_of_measurement + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.plant) diff --git a/homeassistant/components/hypontech/strings.json b/homeassistant/components/hypontech/strings.json new file mode 100644 index 00000000000000..b2d18800fe0f01 --- /dev/null +++ b/homeassistant/components/hypontech/strings.json @@ -0,0 +1,52 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "wrong_account": "The provided credentials are for a different Hypontech Cloud account." + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "[%key:component::hypontech::config::step::user::data_description::password%]", + "username": "[%key:component::hypontech::config::step::user::data_description::username%]" + }, + "description": "Your Hypontech Cloud credentials have expired. Please re-enter your credentials to continue using this integration." + }, + "user": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "Your Hypontech Cloud account password.", + "username": "Your Hypontech Cloud account username." + } + } + } + }, + "entity": { + "sensor": { + "lifetime_energy": { + "name": "Lifetime energy" + }, + "today_energy": { + "name": "Today energy" + } + } + }, + "exceptions": { + "connection_error": { + "message": "Failed to connect to Hypontech Cloud. Maybe you make too frequent connection from multiple devices in your network." + } + } +} diff --git a/homeassistant/components/icloud/manifest.json b/homeassistant/components/icloud/manifest.json index ea8f52732cf29c..f8c45b3526ab6f 100644 --- a/homeassistant/components/icloud/manifest.json +++ b/homeassistant/components/icloud/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["keyrings.alt", "pyicloud"], - "requirements": ["pyicloud==2.3.0"] + "requirements": ["pyicloud==2.4.1"] } diff --git a/homeassistant/components/idasen_desk/manifest.json b/homeassistant/components/idasen_desk/manifest.json index 9e83347f098fc2..9ed011498442ae 100644 --- a/homeassistant/components/idasen_desk/manifest.json +++ b/homeassistant/components/idasen_desk/manifest.json @@ -13,5 +13,5 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["idasen-ha==2.6.3"] + "requirements": ["idasen-ha==2.6.4"] } diff --git a/homeassistant/components/idrive_e2/backup.py b/homeassistant/components/idrive_e2/backup.py index 6d58742db8e111..2fcdcf73ecc8d7 100644 --- a/homeassistant/components/idrive_e2/backup.py +++ b/homeassistant/components/idrive_e2/backup.py @@ -15,6 +15,7 @@ BackupAgent, BackupAgentError, BackupNotFound, + OnProgressCallback, suggested_filename, ) from homeassistant.core import HomeAssistant, callback @@ -127,6 +128,7 @@ async def async_upload_backup( *, open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], backup: AgentBackup, + on_progress: OnProgressCallback, **kwargs: Any, ) -> None: """Upload a backup. @@ -329,14 +331,14 @@ async def _list_backups(self) -> dict[str, AgentBackup]: return self._backup_cache backups = {} - response = await cast(Any, self._client).list_objects_v2(Bucket=self._bucket) - - # Filter for metadata files only - metadata_files = [ - obj - for obj in response.get("Contents", []) - if obj["Key"].endswith(".metadata.json") - ] + paginator = self._client.get_paginator("list_objects_v2") + metadata_files: list[dict[str, Any]] = [] + async for page in paginator.paginate(Bucket=self._bucket): + metadata_files.extend( + obj + for obj in page.get("Contents", []) + if obj["Key"].endswith(".metadata.json") + ) for metadata_file in metadata_files: try: diff --git a/homeassistant/components/ifttt/strings.json b/homeassistant/components/ifttt/strings.json index 817e6a7872e718..13b4181fc85051 100644 --- a/homeassistant/components/ifttt/strings.json +++ b/homeassistant/components/ifttt/strings.json @@ -2,6 +2,7 @@ "config": { "abort": { "cloud_not_connected": "[%key:common::config_flow::abort::cloud_not_connected%]", + "reconfigure_successful": "**Reconfiguration was successful**\n\nGo to the \"Make a web request\" action from the [IFTTT webhook applet]({applet_url}) and update the webhook with the following settings:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data.", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]" }, @@ -9,6 +10,10 @@ "default": "To send events to Home Assistant, you will need to use the \"Make a web request\" action from the [IFTTT webhook applet]({applet_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." }, "step": { + "reconfigure": { + "description": "Are you sure you want to reconfigure IFTTT?", + "title": "Reconfigure IFTTT webhook applet" + }, "user": { "description": "Are you sure you want to set up IFTTT?", "title": "Set up the IFTTT webhook applet" diff --git a/homeassistant/components/iglo/light.py b/homeassistant/components/iglo/light.py index d356ad05541865..3fb09f0eac62b3 100644 --- a/homeassistant/components/iglo/light.py +++ b/homeassistant/components/iglo/light.py @@ -68,7 +68,7 @@ def name(self): return self._name @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" return int((self._lamp.state()["brightness"] / 200.0) * 255) @@ -97,22 +97,22 @@ def min_color_temp_kelvin(self) -> int: return self._lamp.min_kelvin @property - def hs_color(self): + def hs_color(self) -> tuple[float, float]: """Return the hs value.""" return color_util.color_RGB_to_hs(*self._lamp.state()["rgb"]) @property - def effect(self): + def effect(self) -> str: """Return the current effect.""" return self._lamp.state()["effect"] @property - def effect_list(self): + def effect_list(self) -> list[str]: """Return the list of supported effects.""" return self._lamp.effect_list() @property - def is_on(self): + def is_on(self) -> bool: """Return true if light is on.""" return self._lamp.state()["on"] diff --git a/homeassistant/components/igloohome/lock.py b/homeassistant/components/igloohome/lock.py index b434c055145ad5..dc79bba9c63357 100644 --- a/homeassistant/components/igloohome/lock.py +++ b/homeassistant/components/igloohome/lock.py @@ -1,6 +1,7 @@ """Implementation of the lock platform.""" from datetime import timedelta +from typing import Any from aiohttp import ClientError from igloohome_api import ( @@ -63,7 +64,7 @@ def __init__( ) self.bridge_id = bridge_id - async def async_lock(self, **kwargs): + async def async_lock(self, **kwargs: Any) -> None: """Lock this lock.""" try: await self.api.create_bridge_proxied_job( @@ -72,7 +73,7 @@ async def async_lock(self, **kwargs): except (ApiException, ClientError) as err: raise HomeAssistantError from err - async def async_unlock(self, **kwargs): + async def async_unlock(self, **kwargs: Any) -> None: """Unlock this lock.""" try: await self.api.create_bridge_proxied_job( @@ -81,7 +82,7 @@ async def async_unlock(self, **kwargs): except (ApiException, ClientError) as err: raise HomeAssistantError from err - async def async_open(self, **kwargs): + async def async_open(self, **kwargs: Any) -> None: """Open (unlatch) this lock.""" try: await self.api.create_bridge_proxied_job( diff --git a/homeassistant/components/ihc/entity.py b/homeassistant/components/ihc/entity.py index 8847ffc9f492c2..b2138eb8aab2e7 100644 --- a/homeassistant/components/ihc/entity.py +++ b/homeassistant/components/ihc/entity.py @@ -1,6 +1,7 @@ """Implementation of a base class for all IHC devices.""" import logging +from typing import Any from ihcsdk.ihccontroller import IHCController @@ -70,7 +71,7 @@ def unique_id(self): return f"{self.controller_id}-{self.ihc_id}" @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" if not self.hass.data[DOMAIN][self.controller_id][CONF_INFO]: return {} diff --git a/homeassistant/components/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json index a37ab4c010a03a..394e1871d29919 100644 --- a/homeassistant/components/image_upload/manifest.json +++ b/homeassistant/components/image_upload/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/image_upload", "integration_type": "system", "quality_scale": "internal", - "requirements": ["Pillow==12.0.0"] + "requirements": ["Pillow==12.1.1"] } diff --git a/homeassistant/components/imgw_pib/__init__.py b/homeassistant/components/imgw_pib/__init__.py index 4bceee51f8e911..f2d30ce34efd34 100644 --- a/homeassistant/components/imgw_pib/__init__.py +++ b/homeassistant/components/imgw_pib/__init__.py @@ -8,7 +8,7 @@ from imgw_pib import ImgwPib from imgw_pib.exceptions import ApiError -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_PLATFORM +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -54,7 +54,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ImgwPibConfigEntry) -> b entity_reg = er.async_get(hass) for key in ("flood_warning", "flood_alarm"): if entity_id := entity_reg.async_get_entity_id( - BINARY_SENSOR_PLATFORM, DOMAIN, f"{coordinator.station_id}_{key}" + BINARY_SENSOR_DOMAIN, DOMAIN, f"{coordinator.station_id}_{key}" ): entity_reg.async_remove(entity_id) diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index af59a4f56294ef..c1d9580facdf6f 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["imgw_pib==2.0.1"] + "requirements": ["imgw_pib==2.0.2"] } diff --git a/homeassistant/components/imgw_pib/sensor.py b/homeassistant/components/imgw_pib/sensor.py index 7084889220c0e4..170736d8f6c972 100644 --- a/homeassistant/components/imgw_pib/sensor.py +++ b/homeassistant/components/imgw_pib/sensor.py @@ -10,7 +10,7 @@ from imgw_pib.model import HydrologicalData from homeassistant.components.sensor import ( - DOMAIN as SENSOR_PLATFORM, + DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -102,7 +102,7 @@ async def async_setup_entry( entity_reg = er.async_get(hass) for key in ("flood_warning_level", "flood_alarm_level"): if entity_id := entity_reg.async_get_entity_id( - SENSOR_PLATFORM, DOMAIN, f"{coordinator.station_id}_{key}" + SENSOR_DOMAIN, DOMAIN, f"{coordinator.station_id}_{key}" ): entity_reg.async_remove(entity_id) diff --git a/homeassistant/components/imgw_pib/strings.json b/homeassistant/components/imgw_pib/strings.json index 17f190c0cb1063..e746d66a945126 100644 --- a/homeassistant/components/imgw_pib/strings.json +++ b/homeassistant/components/imgw_pib/strings.json @@ -24,6 +24,7 @@ "hydrological_alert": { "name": "Hydrological alert", "state": { + "exceeding_the_alarm_level": "Exceeding the alarm level", "exceeding_the_warning_level": "Exceeding the warning level", "hydrological_drought": "Hydrological drought", "no_alert": "No alert", diff --git a/homeassistant/components/indevolt/__init__.py b/homeassistant/components/indevolt/__init__.py new file mode 100644 index 00000000000000..7a4341d602be4d --- /dev/null +++ b/homeassistant/components/indevolt/__init__.py @@ -0,0 +1,34 @@ +"""Home Assistant integration for indevolt device.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import IndevoltConfigEntry, IndevoltCoordinator + +PLATFORMS: list[Platform] = [ + Platform.BUTTON, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: IndevoltConfigEntry) -> bool: + """Set up indevolt integration entry using given configuration.""" + coordinator = IndevoltCoordinator(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: 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/button.py b/homeassistant/components/indevolt/button.py new file mode 100644 index 00000000000000..6abcf50048bee9 --- /dev/null +++ b/homeassistant/components/indevolt/button.py @@ -0,0 +1,70 @@ +"""Button platform for Indevolt integration.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Final + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +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 IndevoltButtonEntityDescription(ButtonEntityDescription): + """Custom entity description class for Indevolt button entities.""" + + generation: list[int] = field(default_factory=lambda: [1, 2]) + + +BUTTONS: Final = ( + IndevoltButtonEntityDescription( + key="stop", + translation_key="stop", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IndevoltConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the button platform for Indevolt.""" + coordinator = entry.runtime_data + device_gen = coordinator.generation + + # Button initialization + async_add_entities( + IndevoltButtonEntity(coordinator=coordinator, description=description) + for description in BUTTONS + if device_gen in description.generation + ) + + +class IndevoltButtonEntity(IndevoltEntity, ButtonEntity): + """Represents a button entity for Indevolt devices.""" + + entity_description: IndevoltButtonEntityDescription + + def __init__( + self, + coordinator: IndevoltCoordinator, + description: IndevoltButtonEntityDescription, + ) -> None: + """Initialize the Indevolt button entity.""" + super().__init__(coordinator) + + self.entity_description = description + self._attr_unique_id = f"{self.serial_number}_{description.key}" + + async def async_press(self) -> None: + """Handle the button press.""" + + await self.coordinator.async_execute_realtime_action([0, 0, 0]) diff --git a/homeassistant/components/indevolt/config_flow.py b/homeassistant/components/indevolt/config_flow.py new file mode 100644 index 00000000000000..feca6c647e5239 --- /dev/null +++ b/homeassistant/components/indevolt/config_flow.py @@ -0,0 +1,115 @@ +"""Config flow for Indevolt integration.""" + +import logging +from typing import Any + +from aiohttp import ClientError +from indevolt_api import IndevoltAPI +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_MODEL +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_GENERATION, CONF_SERIAL_NUMBER, DEFAULT_PORT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class IndevoltConfigFlow(ConfigFlow, domain=DOMAIN): + """Configuration flow for Indevolt integration.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial user configuration step.""" + errors: dict[str, str] = {} + + # Attempt to setup from user input + if user_input is not None: + errors, device_data = await self._async_validate_input(user_input) + + if not errors and device_data: + await self.async_set_unique_id(device_data[CONF_SERIAL_NUMBER]) + + # Handle initial setup + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"INDEVOLT {device_data[CONF_MODEL]}", + data={ + CONF_HOST: user_input[CONF_HOST], + **device_data, + }, + ) + + # Retrieve user input + return self.async_show_form( + step_id="user", + 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 reconfiguration of the Indevolt device host.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + # Attempt to setup from user input + if user_input is not None: + errors, device_data = await self._async_validate_input(user_input) + + if not errors and device_data: + await self.async_set_unique_id(device_data[CONF_SERIAL_NUMBER]) + self._abort_if_unique_id_mismatch(reason="different_device") + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={ + CONF_HOST: user_input[CONF_HOST], + **device_data, + }, + ) + + # Retrieve user input (prefilled form) + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + vol.Schema({vol.Required(CONF_HOST): str}), + reconfigure_entry.data, + ), + errors=errors, + ) + + async def _async_validate_input( + self, user_input: dict[str, Any] + ) -> tuple[dict[str, str], dict[str, Any] | None]: + """Validate user input and return errors dict and device data.""" + errors = {} + device_data = None + + try: + device_data = await self._async_get_device_data(user_input[CONF_HOST]) + except TimeoutError: + errors["base"] = "timeout" + except ConnectionError, ClientError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unknown error occurred while verifying device") + errors["base"] = "unknown" + + return errors, device_data + + async def _async_get_device_data(self, host: str) -> dict[str, Any]: + """Get device data (type, serial number, generation) from API.""" + api = IndevoltAPI(host, DEFAULT_PORT, async_get_clientsession(self.hass)) + config_data = await api.get_config() + device_data = config_data["device"] + + return { + CONF_SERIAL_NUMBER: device_data["sn"], + CONF_GENERATION: device_data["generation"], + CONF_MODEL: device_data["type"], + } diff --git a/homeassistant/components/indevolt/const.py b/homeassistant/components/indevolt/const.py new file mode 100644 index 00000000000000..3b469282a643c3 --- /dev/null +++ b/homeassistant/components/indevolt/const.py @@ -0,0 +1,118 @@ +"""Constants for the Indevolt integration.""" + +from typing import Final + +DOMAIN: Final = "indevolt" + +# Default configurations +DEFAULT_PORT: Final = 8080 + +# Config entry fields +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", + ], + 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", + ], +} diff --git a/homeassistant/components/indevolt/coordinator.py b/homeassistant/components/indevolt/coordinator.py new file mode 100644 index 00000000000000..19320eec5441f6 --- /dev/null +++ b/homeassistant/components/indevolt/coordinator.py @@ -0,0 +1,169 @@ +"""Home Assistant integration for Indevolt device.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any, Final + +from aiohttp import ClientError +from indevolt_api import IndevoltAPI, TimeOutException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_MODEL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONF_GENERATION, + CONF_SERIAL_NUMBER, + DEFAULT_PORT, + DOMAIN, + ENERGY_MODE_READ_KEY, + ENERGY_MODE_WRITE_KEY, + PORTABLE_MODE, + REALTIME_ACTION_KEY, + REALTIME_ACTION_MODE, + SENSOR_KEYS, +) + +_LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL: Final = 30 + +type IndevoltConfigEntry = ConfigEntry[IndevoltCoordinator] + + +class DeviceTimeoutError(HomeAssistantError): + """Raised when device push times out.""" + + +class DeviceConnectionError(HomeAssistantError): + """Raised when device push fails due to connection issues.""" + + +class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Coordinator for fetching and pushing data to indevolt devices.""" + + friendly_name: str + config_entry: IndevoltConfigEntry + firmware_version: str | None + serial_number: str + device_model: str + generation: int + + def __init__(self, hass: HomeAssistant, entry: IndevoltConfigEntry) -> None: + """Initialize the indevolt coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=SCAN_INTERVAL), + config_entry=entry, + ) + + # Initialize Indevolt API + self.api = IndevoltAPI( + host=entry.data[CONF_HOST], + port=DEFAULT_PORT, + 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] + + async def _async_setup(self) -> None: + """Fetch device info once on boot.""" + try: + config_data = await self.api.get_config() + except TimeOutException as err: + raise ConfigEntryNotReady( + f"Device config retrieval timed out: {err}" + ) from err + + # Cache device information + device_data = config_data.get("device", {}) + + self.firmware_version = device_data.get("fw") + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch raw JSON data from the device.""" + sensor_keys = SENSOR_KEYS[self.generation] + + try: + return await self.api.fetch_data(sensor_keys) + except TimeOutException as err: + raise UpdateFailed(f"Device update timed out: {err}") from err + + async def async_push_data(self, sensor_key: str, value: Any) -> bool: + """Push/write data values to given key on the device.""" + try: + return await self.api.set_data(sensor_key, value) + except TimeOutException as err: + raise DeviceTimeoutError(f"Device push timed out: {err}") from err + except (ClientError, ConnectionError, OSError) as err: + raise DeviceConnectionError(f"Device push failed: {err}") from err + + async def async_switch_energy_mode( + self, target_mode: int, refresh: bool = True + ) -> None: + """Attempt to switch device to given energy mode.""" + current_mode = self.data.get(ENERGY_MODE_READ_KEY) + + # Ensure current energy mode is known + if current_mode is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="failed_to_retrieve_current_energy_mode", + ) + + # Ensure device is not in "Outdoor/Portable mode" + if current_mode == PORTABLE_MODE: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="energy_mode_change_unavailable_outdoor_portable", + ) + + # Switch energy mode if required + if current_mode != target_mode: + try: + success = await self.async_push_data(ENERGY_MODE_WRITE_KEY, target_mode) + except (DeviceTimeoutError, DeviceConnectionError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="failed_to_switch_energy_mode", + ) from err + + if not success: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="failed_to_switch_energy_mode", + ) + + if refresh: + await self.async_request_refresh() + + async def async_execute_realtime_action(self, action: list[int]) -> None: + """Switch mode, execute action, and refresh for real-time control.""" + + await self.async_switch_energy_mode(REALTIME_ACTION_MODE, refresh=False) + + try: + success = await self.async_push_data(REALTIME_ACTION_KEY, action) + + except (DeviceTimeoutError, DeviceConnectionError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="failed_to_execute_realtime_action", + ) from err + + if not success: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="failed_to_execute_realtime_action", + ) + + await self.async_request_refresh() diff --git a/homeassistant/components/indevolt/diagnostics.py b/homeassistant/components/indevolt/diagnostics.py new file mode 100644 index 00000000000000..fadc6e63403ec9 --- /dev/null +++ b/homeassistant/components/indevolt/diagnostics.py @@ -0,0 +1,46 @@ +"""Diagnostics support for Indevolt integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from .const import CONF_SERIAL_NUMBER +from .coordinator import IndevoltConfigEntry + +# Redact sensitive information from diagnostics (host and serial numbers) +TO_REDACT = { + CONF_HOST, + CONF_SERIAL_NUMBER, + "0", + "9008", + "9032", + "9051", + "9070", + "9218", + "9165", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: IndevoltConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + + device_info = { + "model": coordinator.device_model, + "generation": coordinator.generation, + "serial_number": coordinator.serial_number, + "firmware_version": coordinator.firmware_version, + } + + return { + "entry_data": async_redact_data(entry.data, TO_REDACT), + "device": async_redact_data(device_info, TO_REDACT), + "coordinator_data": async_redact_data(coordinator.data, TO_REDACT), + "last_update_success": coordinator.last_update_success, + } diff --git a/homeassistant/components/indevolt/entity.py b/homeassistant/components/indevolt/entity.py new file mode 100644 index 00000000000000..da87036a33a3da --- /dev/null +++ b/homeassistant/components/indevolt/entity.py @@ -0,0 +1,31 @@ +"""Base entity for Indevolt integration.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import IndevoltCoordinator + + +class IndevoltEntity(CoordinatorEntity[IndevoltCoordinator]): + """Base Indevolt entity with up-to-date device info.""" + + _attr_has_entity_name = True + + @property + def serial_number(self) -> str: + """Return the device serial number.""" + return self.coordinator.serial_number + + @property + def device_info(self) -> DeviceInfo: + """Return device information for registry.""" + coordinator = self.coordinator + return DeviceInfo( + identifiers={(DOMAIN, coordinator.serial_number)}, + manufacturer="INDEVOLT", + serial_number=coordinator.serial_number, + model=coordinator.device_model, + sw_version=coordinator.firmware_version, + hw_version=str(coordinator.generation), + ) diff --git a/homeassistant/components/indevolt/manifest.json b/homeassistant/components/indevolt/manifest.json new file mode 100644 index 00000000000000..2e67b487bd60dc --- /dev/null +++ b/homeassistant/components/indevolt/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "indevolt", + "name": "Indevolt", + "codeowners": ["@xirt"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/indevolt", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["indevolt-api==1.2.3"] +} diff --git a/homeassistant/components/indevolt/number.py b/homeassistant/components/indevolt/number.py new file mode 100644 index 00000000000000..0831e9b9657be1 --- /dev/null +++ b/homeassistant/components/indevolt/number.py @@ -0,0 +1,142 @@ +"""Number platform for Indevolt integration.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Final + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.const import PERCENTAGE, UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +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 IndevoltNumberEntityDescription(NumberEntityDescription): + """Custom entity description class for Indevolt number entities.""" + + generation: list[int] = field(default_factory=lambda: [1, 2]) + read_key: str + write_key: str + + +NUMBERS: Final = ( + IndevoltNumberEntityDescription( + key="discharge_limit", + generation=[2], + translation_key="discharge_limit", + read_key="6105", + write_key="1142", + 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", + native_min_value=0, + native_max_value=2400, + native_step=100, + native_unit_of_measurement=UnitOfPower.WATT, + device_class=NumberDeviceClass.POWER, + ), + IndevoltNumberEntityDescription( + key="inverter_input_limit", + generation=[2], + translation_key="inverter_input_limit", + read_key="11009", + write_key="1138", + native_min_value=100, + native_max_value=2400, + native_step=100, + native_unit_of_measurement=UnitOfPower.WATT, + device_class=NumberDeviceClass.POWER, + ), + IndevoltNumberEntityDescription( + key="feedin_power_limit", + generation=[2], + translation_key="feedin_power_limit", + read_key="11010", + write_key="1146", + native_min_value=0, + native_max_value=2400, + native_step=100, + native_unit_of_measurement=UnitOfPower.WATT, + device_class=NumberDeviceClass.POWER, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IndevoltConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the number platform for Indevolt.""" + coordinator = entry.runtime_data + device_gen = coordinator.generation + + # Number initialization + async_add_entities( + IndevoltNumberEntity(coordinator, description) + for description in NUMBERS + if device_gen in description.generation + ) + + +class IndevoltNumberEntity(IndevoltEntity, NumberEntity): + """Represents a number entity for Indevolt devices.""" + + entity_description: IndevoltNumberEntityDescription + _attr_mode = NumberMode.BOX + + def __init__( + self, + coordinator: IndevoltCoordinator, + description: IndevoltNumberEntityDescription, + ) -> None: + """Initialize the Indevolt number entity.""" + super().__init__(coordinator) + + self.entity_description = description + self._attr_unique_id = f"{self.serial_number}_{description.key}" + + @property + def native_value(self) -> int | None: + """Return the current value of the entity.""" + raw_value = self.coordinator.data.get(self.entity_description.read_key) + if raw_value is None: + return None + + return int(raw_value) + + async def async_set_native_value(self, value: float) -> None: + """Set a new value for the entity.""" + + int_value = int(value) + success = await self.coordinator.async_push_data( + self.entity_description.write_key, int_value + ) + + if success: + await self.coordinator.async_request_refresh() + + else: + raise HomeAssistantError(f"Failed to set value {int_value} for {self.name}") diff --git a/homeassistant/components/indevolt/quality_scale.yaml b/homeassistant/components/indevolt/quality_scale.yaml new file mode 100644 index 00000000000000..9e948fd93653ad --- /dev/null +++ b/homeassistant/components/indevolt/quality_scale.yaml @@ -0,0 +1,92 @@ +rules: + # Bronze (mandatory for core integrations) + action-setup: + status: exempt + comment: Integration does not register custom actions + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not register custom actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Integration does not subscribe to entity events, uses coordinator pattern + 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 register custom actions + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration has no user-configurable parameters beyond setup + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: Integration uses local device with no authentication + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: Integration does not support network discovery + discovery: + status: exempt + comment: Integration does not support network discovery + docs-data-update: + 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 + dynamic-devices: + status: exempt + comment: Integration represents a single device, not a hub with multiple devices + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: + status: todo + icon-translations: + status: todo + reconfiguration-flow: done + repair-issues: + status: exempt + comment: No repair issues needed for current functionality + stale-devices: + status: exempt + comment: Integration represents a single device, not a hub with multiple devices + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: + status: todo diff --git a/homeassistant/components/indevolt/select.py b/homeassistant/components/indevolt/select.py new file mode 100644 index 00000000000000..2850ae2da522ea --- /dev/null +++ b/homeassistant/components/indevolt/select.py @@ -0,0 +1,111 @@ +"""Select platform for Indevolt integration.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Final + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import IndevoltConfigEntry +from .coordinator import IndevoltCoordinator +from .entity import IndevoltEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class IndevoltSelectEntityDescription(SelectEntityDescription): + """Custom entity description class for Indevolt select entities.""" + + read_key: str + write_key: str + value_to_option: dict[int, str] + unavailable_values: list[int] = field(default_factory=list) + generation: list[int] = field(default_factory=lambda: [1, 2]) + + +SELECTS: Final = ( + IndevoltSelectEntityDescription( + key="energy_mode", + translation_key="energy_mode", + read_key="7101", + write_key="47005", + value_to_option={ + 1: "self_consumed_prioritized", + 4: "real_time_control", + 5: "charge_discharge_schedule", + }, + unavailable_values=[0], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IndevoltConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the select platform for Indevolt.""" + coordinator = entry.runtime_data + device_gen = coordinator.generation + + # Select initialization + async_add_entities( + IndevoltSelectEntity(coordinator=coordinator, description=description) + for description in SELECTS + if device_gen in description.generation + ) + + +class IndevoltSelectEntity(IndevoltEntity, SelectEntity): + """Represents a select entity for Indevolt devices.""" + + entity_description: IndevoltSelectEntityDescription + + def __init__( + self, + coordinator: IndevoltCoordinator, + description: IndevoltSelectEntityDescription, + ) -> None: + """Initialize the Indevolt select entity.""" + super().__init__(coordinator) + + self.entity_description = description + self._attr_unique_id = f"{self.serial_number}_{description.key}" + self._attr_options = list(description.value_to_option.values()) + self._option_to_value = {v: k for k, v in description.value_to_option.items()} + + @property + def current_option(self) -> str | None: + """Return the currently selected option.""" + raw_value = self.coordinator.data.get(self.entity_description.read_key) + if raw_value is None: + return None + + return self.entity_description.value_to_option.get(raw_value) + + @property + def available(self) -> bool: + """Return False when the device is in a mode that cannot be selected.""" + if not super().available: + return False + + raw_value = self.coordinator.data.get(self.entity_description.read_key) + return raw_value not in self.entity_description.unavailable_values + + async def async_select_option(self, option: str) -> None: + """Select a new option.""" + value = self._option_to_value[option] + success = await self.coordinator.async_push_data( + self.entity_description.write_key, value + ) + + if success: + await self.coordinator.async_request_refresh() + + else: + raise HomeAssistantError(f"Failed to set option {option} for {self.name}") diff --git a/homeassistant/components/indevolt/sensor.py b/homeassistant/components/indevolt/sensor.py new file mode 100644 index 00000000000000..75040bf8e7eeec --- /dev/null +++ b/homeassistant/components/indevolt/sensor.py @@ -0,0 +1,703 @@ +"""Sensor platform for Indevolt integration.""" + +from dataclasses import dataclass, field +from typing import Final + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfFrequency, + UnitOfPower, + UnitOfTemperature, +) +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 IndevoltSensorEntityDescription(SensorEntityDescription): + """Custom entity description class for Indevolt sensors.""" + + state_mapping: dict[str | int, str] = field(default_factory=dict) + generation: list[int] = field(default_factory=lambda: [1, 2]) + + +SENSORS: Final = ( + # System Operating Information + IndevoltSensorEntityDescription( + key="606", + translation_key="mode", + state_mapping={"1000": "main", "1001": "sub", "1002": "standalone"}, + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="7101", + translation_key="energy_mode", + state_mapping={ + 0: "outdoor_portable", + 1: "self_consumed_prioritized", + 4: "real_time_control", + 5: "charge_discharge_schedule", + }, + device_class=SensorDeviceClass.ENUM, + ), + IndevoltSensorEntityDescription( + key="142", + generation=[2], + translation_key="rated_capacity", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IndevoltSensorEntityDescription( + key="6105", + generation=[1], + translation_key="rated_capacity", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IndevoltSensorEntityDescription( + key="2101", + translation_key="ac_input_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + IndevoltSensorEntityDescription( + key="2108", + translation_key="ac_output_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + IndevoltSensorEntityDescription( + key="667", + generation=[2], + translation_key="bypass_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + # Electrical Energy Information + IndevoltSensorEntityDescription( + key="2107", + translation_key="total_ac_input_energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IndevoltSensorEntityDescription( + key="2104", + generation=[2], + translation_key="total_ac_output_energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IndevoltSensorEntityDescription( + key="2105", + generation=[2], + translation_key="off_grid_output_energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IndevoltSensorEntityDescription( + key="11034", + generation=[2], + translation_key="bypass_input_energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IndevoltSensorEntityDescription( + key="6004", + generation=[2], + translation_key="battery_daily_charging_energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IndevoltSensorEntityDescription( + key="6005", + generation=[2], + translation_key="battery_daily_discharging_energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IndevoltSensorEntityDescription( + key="6006", + generation=[2], + translation_key="battery_total_charging_energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IndevoltSensorEntityDescription( + key="6007", + generation=[2], + translation_key="battery_total_discharging_energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + # Electricity Meter Status + IndevoltSensorEntityDescription( + key="11016", + generation=[2], + translation_key="meter_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + IndevoltSensorEntityDescription( + key="21028", + generation=[1], + translation_key="meter_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + # Grid information + IndevoltSensorEntityDescription( + key="2600", + generation=[2], + translation_key="grid_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="2612", + generation=[2], + translation_key="grid_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + # Battery Pack Operating Parameters + IndevoltSensorEntityDescription( + key="6000", + translation_key="battery_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + IndevoltSensorEntityDescription( + key="6001", + translation_key="battery_charge_discharge_state", + state_mapping={1000: "static", 1001: "charging", 1002: "discharging"}, + device_class=SensorDeviceClass.ENUM, + ), + IndevoltSensorEntityDescription( + key="6002", + translation_key="battery_soc", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + ), + # PV Operating Parameters + IndevoltSensorEntityDescription( + key="1501", + translation_key="dc_output_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + IndevoltSensorEntityDescription( + key="1502", + translation_key="daily_production", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IndevoltSensorEntityDescription( + key="1505", + translation_key="cumulative_production", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IndevoltSensorEntityDescription( + key="1632", + generation=[2], + translation_key="dc_input_current_1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="1600", + generation=[2], + translation_key="dc_input_voltage_1", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="1664", + translation_key="dc_input_power_1", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="1633", + generation=[2], + translation_key="dc_input_current_2", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="1601", + generation=[2], + translation_key="dc_input_voltage_2", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="1665", + translation_key="dc_input_power_2", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="1634", + generation=[2], + translation_key="dc_input_current_3", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="1602", + generation=[2], + translation_key="dc_input_voltage_3", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="1666", + generation=[2], + translation_key="dc_input_power_3", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="1635", + generation=[2], + translation_key="dc_input_current_4", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="1603", + generation=[2], + translation_key="dc_input_voltage_4", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="1667", + generation=[2], + translation_key="dc_input_power_4", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + # Battery Pack Serial Numbers + IndevoltSensorEntityDescription( + key="9008", + generation=[2], + translation_key="main_serial_number", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="9032", + generation=[2], + translation_key="battery_pack_1_serial_number", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="9051", + generation=[2], + translation_key="battery_pack_2_serial_number", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="9070", + generation=[2], + translation_key="battery_pack_3_serial_number", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="9165", + generation=[2], + translation_key="battery_pack_4_serial_number", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="9218", + generation=[2], + translation_key="battery_pack_5_serial_number", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + # Battery Pack SOC + IndevoltSensorEntityDescription( + key="9000", + generation=[2], + translation_key="main_soc", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="9016", + generation=[2], + translation_key="battery_pack_1_soc", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="9035", + generation=[2], + translation_key="battery_pack_2_soc", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="9054", + generation=[2], + translation_key="battery_pack_3_soc", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="9149", + generation=[2], + translation_key="battery_pack_4_soc", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="9202", + generation=[2], + translation_key="battery_pack_5_soc", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + # Battery Pack Temperature + IndevoltSensorEntityDescription( + key="9012", + generation=[2], + translation_key="main_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="9030", + generation=[2], + translation_key="battery_pack_1_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="9049", + generation=[2], + translation_key="battery_pack_2_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="9068", + generation=[2], + translation_key="battery_pack_3_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="9163", + generation=[2], + translation_key="battery_pack_4_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="9216", + generation=[2], + translation_key="battery_pack_5_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + # Battery Pack Voltage + IndevoltSensorEntityDescription( + key="9004", + generation=[2], + translation_key="main_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="9020", + generation=[2], + translation_key="battery_pack_1_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="9039", + generation=[2], + translation_key="battery_pack_2_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="9058", + generation=[2], + translation_key="battery_pack_3_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="9153", + generation=[2], + translation_key="battery_pack_4_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="9206", + generation=[2], + translation_key="battery_pack_5_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + # Battery Pack Current + IndevoltSensorEntityDescription( + key="9013", + generation=[2], + translation_key="main_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="19173", + generation=[2], + translation_key="battery_pack_1_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="19174", + generation=[2], + translation_key="battery_pack_2_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="19175", + generation=[2], + translation_key="battery_pack_3_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="19176", + generation=[2], + translation_key="battery_pack_4_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + IndevoltSensorEntityDescription( + key="19177", + generation=[2], + translation_key="battery_pack_5_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), +) + +# 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 +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IndevoltConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the sensor platform for Indevolt.""" + coordinator = entry.runtime_data + device_gen = coordinator.generation + + excluded_keys: set[str] = set() + for pack_keys in BATTERY_PACK_SENSOR_KEYS: + sn_key = pack_keys[0] + + if not coordinator.data.get(sn_key): + excluded_keys.update(pack_keys) + + # Sensor initialization + async_add_entities( + IndevoltSensorEntity(coordinator, description) + for description in SENSORS + if device_gen in description.generation and description.key not in excluded_keys + ) + + +class IndevoltSensorEntity(IndevoltEntity, SensorEntity): + """Represents a sensor entity for Indevolt devices.""" + + entity_description: IndevoltSensorEntityDescription + + def __init__( + self, + coordinator: IndevoltCoordinator, + description: IndevoltSensorEntityDescription, + ) -> None: + """Initialize the Indevolt sensor entity.""" + super().__init__(coordinator) + + self.entity_description = description + self._attr_unique_id = f"{self.serial_number}_{description.key}" + + # Sort options (prevent randomization) for ENUM values + if description.device_class == SensorDeviceClass.ENUM: + self._attr_options = sorted(set(description.state_mapping.values())) + + @property + def native_value(self) -> str | int | float | None: + """Return the current value of the sensor in its native unit.""" + raw_value = self.coordinator.data.get(self.entity_description.key) + if raw_value is None: + return None + + # Return descriptions for ENUM values + if self.entity_description.device_class == SensorDeviceClass.ENUM: + return self.entity_description.state_mapping.get(raw_value) + + return raw_value diff --git a/homeassistant/components/indevolt/strings.json b/homeassistant/components/indevolt/strings.json new file mode 100644 index 00000000000000..8b127e3cce677c --- /dev/null +++ b/homeassistant/components/indevolt/strings.json @@ -0,0 +1,312 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "Failed to connect (aborted)", + "different_device": "The device at the new host has a different serial number. Please ensure the new host is the same device.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "timeout": "[%key:common::config_flow::error::timeout_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::indevolt::config::step::user::data_description::host%]" + }, + "description": "Update the connection details for your Indevolt device.", + "title": "Reconfigure Indevolt device" + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The host of the Indevolt device" + }, + "description": "Enter the connection details for your Indevolt device.", + "title": "Connect to Indevolt device" + } + } + }, + "entity": { + "button": { + "stop": { + "name": "Enable standby mode" + } + }, + "number": { + "discharge_limit": { + "name": "Discharge limit" + }, + "feedin_power_limit": { + "name": "Feed-in power limit" + }, + "inverter_input_limit": { + "name": "Inverter input limit" + }, + "max_ac_output_power": { + "name": "Max AC output power" + } + }, + "select": { + "energy_mode": { + "name": "[%key:component::indevolt::entity::sensor::energy_mode::name%]", + "state": { + "charge_discharge_schedule": "[%key:component::indevolt::entity::sensor::energy_mode::state::charge_discharge_schedule%]", + "real_time_control": "[%key:component::indevolt::entity::sensor::energy_mode::state::real_time_control%]", + "self_consumed_prioritized": "[%key:component::indevolt::entity::sensor::energy_mode::state::self_consumed_prioritized%]" + } + } + }, + "sensor": { + "ac_input_power": { + "name": "AC input power" + }, + "ac_output_power": { + "name": "AC output power" + }, + "battery_charge_discharge_state": { + "name": "Battery charge/discharge state", + "state": { + "charging": "[%key:common::state::charging%]", + "discharging": "[%key:common::state::discharging%]", + "static": "Static" + } + }, + "battery_daily_charging_energy": { + "name": "Battery daily charging energy" + }, + "battery_daily_discharging_energy": { + "name": "Battery daily discharging energy" + }, + "battery_pack_1_current": { + "name": "Battery pack 1 current" + }, + "battery_pack_1_serial_number": { + "name": "Battery pack 1 SN" + }, + "battery_pack_1_soc": { + "name": "Battery pack 1 SOC" + }, + "battery_pack_1_temperature": { + "name": "Battery pack 1 temperature" + }, + "battery_pack_1_voltage": { + "name": "Battery pack 1 voltage" + }, + "battery_pack_2_current": { + "name": "Battery pack 2 current" + }, + "battery_pack_2_serial_number": { + "name": "Battery pack 2 SN" + }, + "battery_pack_2_soc": { + "name": "Battery pack 2 SOC" + }, + "battery_pack_2_temperature": { + "name": "Battery pack 2 temperature" + }, + "battery_pack_2_voltage": { + "name": "Battery pack 2 voltage" + }, + "battery_pack_3_current": { + "name": "Battery pack 3 current" + }, + "battery_pack_3_serial_number": { + "name": "Battery pack 3 SN" + }, + "battery_pack_3_soc": { + "name": "Battery pack 3 SOC" + }, + "battery_pack_3_temperature": { + "name": "Battery pack 3 temperature" + }, + "battery_pack_3_voltage": { + "name": "Battery pack 3 voltage" + }, + "battery_pack_4_current": { + "name": "Battery pack 4 current" + }, + "battery_pack_4_serial_number": { + "name": "Battery pack 4 SN" + }, + "battery_pack_4_soc": { + "name": "Battery pack 4 SOC" + }, + "battery_pack_4_temperature": { + "name": "Battery pack 4 temperature" + }, + "battery_pack_4_voltage": { + "name": "Battery pack 4 voltage" + }, + "battery_pack_5_current": { + "name": "Battery pack 5 current" + }, + "battery_pack_5_serial_number": { + "name": "Battery pack 5 SN" + }, + "battery_pack_5_soc": { + "name": "Battery pack 5 SOC" + }, + "battery_pack_5_temperature": { + "name": "Battery pack 5 temperature" + }, + "battery_pack_5_voltage": { + "name": "Battery pack 5 voltage" + }, + "battery_power": { + "name": "Battery power" + }, + "battery_soc": { + "name": "Battery SOC" + }, + "battery_total_charging_energy": { + "name": "Battery total charging energy" + }, + "battery_total_discharging_energy": { + "name": "Battery total discharging energy" + }, + "bypass_input_energy": { + "name": "Bypass input energy" + }, + "bypass_power": { + "name": "Bypass power" + }, + "cumulative_production": { + "name": "Cumulative production" + }, + "daily_production": { + "name": "Daily production" + }, + "dc_input_current_1": { + "name": "DC input current 1" + }, + "dc_input_current_2": { + "name": "DC input current 2" + }, + "dc_input_current_3": { + "name": "DC input current 3" + }, + "dc_input_current_4": { + "name": "DC input current 4" + }, + "dc_input_power_1": { + "name": "DC input power 1" + }, + "dc_input_power_2": { + "name": "DC input power 2" + }, + "dc_input_power_3": { + "name": "DC input power 3" + }, + "dc_input_power_4": { + "name": "DC input power 4" + }, + "dc_input_voltage_1": { + "name": "DC input voltage 1" + }, + "dc_input_voltage_2": { + "name": "DC input voltage 2" + }, + "dc_input_voltage_3": { + "name": "DC input voltage 3" + }, + "dc_input_voltage_4": { + "name": "DC input voltage 4" + }, + "dc_output_power": { + "name": "DC output power" + }, + "energy_mode": { + "name": "Energy mode", + "state": { + "charge_discharge_schedule": "Charge/discharge schedule", + "outdoor_portable": "Outdoor portable", + "real_time_control": "Real-time control", + "self_consumed_prioritized": "Self-consumed prioritized" + } + }, + "grid_frequency": { + "name": "Grid frequency" + }, + "grid_voltage": { + "name": "Grid voltage" + }, + "main_current": { + "name": "Main current" + }, + "main_serial_number": { + "name": "Main serial number" + }, + "main_soc": { + "name": "Main SOC" + }, + "main_temperature": { + "name": "Main temperature" + }, + "main_voltage": { + "name": "Main voltage" + }, + "meter_power": { + "name": "Meter power" + }, + "mode": { + "name": "Device mode", + "state": { + "main": "Cluster (main)", + "standalone": "Standalone", + "sub": "Cluster (sub)" + } + }, + "off_grid_output_energy": { + "name": "Off-grid output energy" + }, + "rated_capacity": { + "name": "Rated capacity" + }, + "serial_number": { + "name": "Serial number" + }, + "total_ac_input_energy": { + "name": "Total AC input energy" + }, + "total_ac_input_energy_gen1": { + "name": "Total AC input energy" + }, + "total_ac_output_energy": { + "name": "Total AC output energy" + } + }, + "switch": { + "bypass": { + "name": "Bypass socket" + }, + "grid_charging": { + "name": "Allow grid charging" + }, + "light": { + "name": "LED indicator" + } + } + }, + "exceptions": { + "energy_mode_change_unavailable_outdoor_portable": { + "message": "Energy mode cannot be changed when the device is in outdoor/portable mode" + }, + "failed_to_execute_realtime_action": { + "message": "Failed to execute real-time action" + }, + "failed_to_retrieve_current_energy_mode": { + "message": "Failed to retrieve current energy mode" + }, + "failed_to_switch_energy_mode": { + "message": "Failed to switch to requested energy mode" + } + } +} diff --git a/homeassistant/components/indevolt/switch.py b/homeassistant/components/indevolt/switch.py new file mode 100644 index 00000000000000..c5bab6053ad963 --- /dev/null +++ b/homeassistant/components/indevolt/switch.py @@ -0,0 +1,131 @@ +"""Switch platform for Indevolt integration.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Final + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +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 IndevoltSwitchEntityDescription(SwitchEntityDescription): + """Custom entity description class for Indevolt switch entities.""" + + read_key: str + write_key: str + read_on_value: int = 1 + read_off_value: int = 0 + generation: list[int] = field(default_factory=lambda: [1, 2]) + + +SWITCHES: Final = ( + IndevoltSwitchEntityDescription( + key="grid_charging", + translation_key="grid_charging", + generation=[2], + read_key="2618", + write_key="1143", + read_on_value=1001, + read_off_value=1000, + device_class=SwitchDeviceClass.SWITCH, + ), + IndevoltSwitchEntityDescription( + key="light", + translation_key="light", + generation=[2], + read_key="7171", + write_key="7265", + device_class=SwitchDeviceClass.SWITCH, + ), + IndevoltSwitchEntityDescription( + key="bypass", + translation_key="bypass", + generation=[2], + read_key="680", + write_key="7266", + device_class=SwitchDeviceClass.SWITCH, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IndevoltConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the switch platform for Indevolt.""" + coordinator = entry.runtime_data + device_gen = coordinator.generation + + # Switch initialization + async_add_entities( + IndevoltSwitchEntity(coordinator=coordinator, description=description) + for description in SWITCHES + if device_gen in description.generation + ) + + +class IndevoltSwitchEntity(IndevoltEntity, SwitchEntity): + """Represents a switch entity for Indevolt devices.""" + + entity_description: IndevoltSwitchEntityDescription + + def __init__( + self, + coordinator: IndevoltCoordinator, + description: IndevoltSwitchEntityDescription, + ) -> None: + """Initialize the Indevolt switch 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 true if switch is on.""" + raw_value = self.coordinator.data.get(self.entity_description.read_key) + if raw_value is None: + return None + + if raw_value == self.entity_description.read_on_value: + return True + + if raw_value == self.entity_description.read_off_value: + return False + + return None + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self._async_toggle(1) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self._async_toggle(0) + + async def _async_toggle(self, value: int) -> None: + """Toggle the switch on/off.""" + success = await self.coordinator.async_push_data( + self.entity_description.write_key, value + ) + + if success: + await self.coordinator.async_request_refresh() + + else: + raise HomeAssistantError(f"Failed to set value {value} for {self.name}") diff --git a/homeassistant/components/influxdb/__init__.py b/homeassistant/components/influxdb/__init__.py index d2c049e1637494..a064d5f580e833 100644 --- a/homeassistant/components/influxdb/__init__.py +++ b/homeassistant/components/influxdb/__init__.py @@ -20,10 +20,14 @@ import urllib3.exceptions import voluptuous as vol +from homeassistant import config as conf_util +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_DOMAIN, CONF_ENTITY_ID, + CONF_EXCLUDE, CONF_HOST, + CONF_INCLUDE, CONF_PASSWORD, CONF_PATH, CONF_PORT, @@ -34,17 +38,14 @@ CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL, - EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import Event, HomeAssistant, State, callback -from homeassistant.helpers import ( - config_validation as cv, - event as event_helper, - state as state_helper, -) +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv, state as state_helper from homeassistant.helpers.entity_values import EntityValues from homeassistant.helpers.entityfilter import ( INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, @@ -61,6 +62,7 @@ CLIENT_ERROR_V2, CODE_INVALID_INPUTS, COMPONENT_CONFIG_SCHEMA_CONNECTION, + COMPONENT_CONFIG_SCHEMA_CONNECTION_VALIDATORS, CONF_API_VERSION, CONF_BUCKET, CONF_COMPONENT_CONFIG, @@ -97,17 +99,18 @@ RE_DIGIT_TAIL, RESUMED_MESSAGE, RETRY_DELAY, - RETRY_INTERVAL, - RETRY_MESSAGE, TEST_QUERY_V1, TEST_QUERY_V2, TIMEOUT, WRITE_ERROR, WROTE_MESSAGE, ) +from .issue import async_create_deprecated_yaml_issue _LOGGER = logging.getLogger(__name__) +type InfluxDBConfigEntry = ConfigEntry[InfluxThread] + def create_influx_url(conf: dict) -> dict: """Build URL used from config inputs and default when necessary.""" @@ -136,7 +139,7 @@ def create_influx_url(conf: dict) -> dict: def validate_version_specific_config(conf: dict) -> dict: """Ensure correct config fields are provided based on API version used.""" - if conf[CONF_API_VERSION] == API_VERSION_2: + if conf.get(CONF_API_VERSION, DEFAULT_API_VERSION) == API_VERSION_2: if CONF_TOKEN not in conf: raise vol.Invalid( f"{CONF_TOKEN} and {CONF_BUCKET} are required when" @@ -192,14 +195,13 @@ def validate_version_specific_config(conf: dict) -> dict: } ) -INFLUX_SCHEMA = vol.All( - _INFLUX_BASE_SCHEMA.extend(COMPONENT_CONFIG_SCHEMA_CONNECTION), - validate_version_specific_config, - create_influx_url, +INFLUX_SCHEMA = _INFLUX_BASE_SCHEMA.extend( + COMPONENT_CONFIG_SCHEMA_CONNECTION_VALIDATORS ) + CONFIG_SCHEMA = vol.Schema( - {DOMAIN: INFLUX_SCHEMA}, + {DOMAIN: vol.All(INFLUX_SCHEMA, validate_version_specific_config)}, extra=vol.ALLOW_EXTRA, ) @@ -349,8 +351,8 @@ def get_influx_connection( # noqa: C901 kwargs[CONF_TOKEN] = conf[CONF_TOKEN] kwargs[INFLUX_CONF_ORG] = conf[CONF_ORG] kwargs[CONF_VERIFY_SSL] = conf[CONF_VERIFY_SSL] - if CONF_SSL_CA_CERT in conf: - kwargs[CONF_SSL_CA_CERT] = conf[CONF_SSL_CA_CERT] + if (cert := conf.get(CONF_SSL_CA_CERT)) is not None: + kwargs[CONF_SSL_CA_CERT] = cert bucket = conf.get(CONF_BUCKET) influx = InfluxDBClientV2(**kwargs) query_api = influx.query_api() @@ -406,31 +408,31 @@ def close_v2(): return InfluxClient(buckets, write_v2, query_v2, close_v2) # Else it's a V1 client - if CONF_SSL_CA_CERT in conf and conf[CONF_VERIFY_SSL]: - kwargs[CONF_VERIFY_SSL] = conf[CONF_SSL_CA_CERT] + if (cert := conf.get(CONF_SSL_CA_CERT)) is not None and conf[CONF_VERIFY_SSL]: + kwargs[CONF_VERIFY_SSL] = cert else: kwargs[CONF_VERIFY_SSL] = conf[CONF_VERIFY_SSL] - if CONF_DB_NAME in conf: - kwargs[CONF_DB_NAME] = conf[CONF_DB_NAME] + if (db_name := conf.get(CONF_DB_NAME)) is not None: + kwargs[CONF_DB_NAME] = db_name - if CONF_USERNAME in conf: - kwargs[CONF_USERNAME] = conf[CONF_USERNAME] + if (user_name := conf.get(CONF_USERNAME)) is not None: + kwargs[CONF_USERNAME] = user_name - if CONF_PASSWORD in conf: - kwargs[CONF_PASSWORD] = conf[CONF_PASSWORD] + if (password := conf.get(CONF_PASSWORD)) is not None: + kwargs[CONF_PASSWORD] = password if CONF_HOST in conf: kwargs[CONF_HOST] = conf[CONF_HOST] - if CONF_PATH in conf: - kwargs[CONF_PATH] = conf[CONF_PATH] + if (path := conf.get(CONF_PATH)) is not None: + kwargs[CONF_PATH] = path - if CONF_PORT in conf: - kwargs[CONF_PORT] = conf[CONF_PORT] + if (port := conf.get(CONF_PORT)) is not None: + kwargs[CONF_PORT] = port - if CONF_SSL in conf: - kwargs[CONF_SSL] = conf[CONF_SSL] + if (ssl := conf.get(CONF_SSL)) is not None: + kwargs[CONF_SSL] = ssl influx = InfluxDBClient(**kwargs) @@ -478,34 +480,91 @@ def close_v1(): return InfluxClient(databases, write_v1, query_v1, close_v1) -def _retry_setup(hass: HomeAssistant, config: ConfigType) -> None: - setup(hass, config) +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the InfluxDB component.""" + if DOMAIN not in config: + return True + hass.async_create_task(_async_setup(hass, config[DOMAIN])) + + return True + + +async def _async_setup(hass: HomeAssistant, config: dict[str, Any]) -> None: + """Import YAML configuration into a config entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + if ( + result.get("type") is FlowResultType.ABORT + and (reason := result["reason"]) != "single_instance_allowed" + ): + async_create_deprecated_yaml_issue(hass, error=reason) + return + + # If we are here, the entry already exists (single instance allowed) + if config.keys() & ( + {k.schema for k in COMPONENT_CONFIG_SCHEMA_CONNECTION} - {CONF_PRECISION} + ): + async_create_deprecated_yaml_issue(hass) + + +async def async_setup_entry(hass: HomeAssistant, entry: InfluxDBConfigEntry) -> bool: + """Set up InfluxDB from a config entry.""" + data = entry.data + + hass_config = await conf_util.async_hass_config_yaml(hass) + + influx_yaml = CONFIG_SCHEMA(hass_config).get(DOMAIN, {}) + default_filter_settings: dict[str, Any] = { + "entity_globs": [], + "entities": [], + "domains": [], + } + + options = { + CONF_RETRY_COUNT: influx_yaml.get(CONF_RETRY_COUNT, 0), + CONF_PRECISION: influx_yaml.get(CONF_PRECISION), + CONF_MEASUREMENT_ATTR: influx_yaml.get( + CONF_MEASUREMENT_ATTR, DEFAULT_MEASUREMENT_ATTR + ), + CONF_DEFAULT_MEASUREMENT: influx_yaml.get(CONF_DEFAULT_MEASUREMENT), + CONF_OVERRIDE_MEASUREMENT: influx_yaml.get(CONF_OVERRIDE_MEASUREMENT), + CONF_INCLUDE: influx_yaml.get(CONF_INCLUDE, default_filter_settings), + CONF_EXCLUDE: influx_yaml.get(CONF_EXCLUDE, default_filter_settings), + CONF_TAGS: influx_yaml.get(CONF_TAGS, {}), + CONF_TAGS_ATTRIBUTES: influx_yaml.get(CONF_TAGS_ATTRIBUTES, []), + CONF_IGNORE_ATTRIBUTES: influx_yaml.get(CONF_IGNORE_ATTRIBUTES, []), + CONF_COMPONENT_CONFIG: influx_yaml.get(CONF_COMPONENT_CONFIG, {}), + CONF_COMPONENT_CONFIG_DOMAIN: influx_yaml.get(CONF_COMPONENT_CONFIG_DOMAIN, {}), + CONF_COMPONENT_CONFIG_GLOB: influx_yaml.get(CONF_COMPONENT_CONFIG_GLOB, {}), + } + + config = data | options -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the InfluxDB component.""" - conf = config[DOMAIN] try: - influx = get_influx_connection(conf, test_write=True) - except ConnectionError as exc: - _LOGGER.error(RETRY_MESSAGE, exc) - event_helper.call_later( - hass, RETRY_INTERVAL, lambda _: _retry_setup(hass, config) - ) - return True + influx = await hass.async_add_executor_job(get_influx_connection, config, True) + except ConnectionError as err: + raise ConfigEntryNotReady(err) from err - event_to_json = _generate_event_to_json(conf) - max_tries = conf.get(CONF_RETRY_COUNT) - instance = hass.data[DOMAIN] = InfluxThread(hass, influx, event_to_json, max_tries) - instance.start() + influx_thread = InfluxThread( + hass, entry, influx, _generate_event_to_json(config), config[CONF_RETRY_COUNT] + ) + await hass.async_add_executor_job(influx_thread.start) - def shutdown(event): - """Shut down the thread.""" - instance.queue.put(None) - instance.join() - influx.close() + entry.runtime_data = influx_thread - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, shutdown) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: InfluxDBConfigEntry) -> bool: + """Unload a config entry.""" + influx_thread = entry.runtime_data + + # Run shutdown in the executor so the event loop isn't blocked + await hass.async_add_executor_job(influx_thread.shutdown) return True @@ -513,7 +572,14 @@ def shutdown(event): class InfluxThread(threading.Thread): """A threaded event handler class.""" - def __init__(self, hass, influx, event_to_json, max_tries): + def __init__( + self, + hass: HomeAssistant, + entry: InfluxDBConfigEntry, + influx: InfluxClient, + event_to_json: Callable[[Event], dict[str, Any] | None], + max_tries: int, + ) -> None: """Initialize the listener.""" threading.Thread.__init__(self, name=DOMAIN) self.queue: queue.SimpleQueue[threading.Event | tuple[float, Event] | None] = ( @@ -523,8 +589,16 @@ def __init__(self, hass, influx, event_to_json, max_tries): self.event_to_json = event_to_json self.max_tries = max_tries self.write_errors = 0 - self.shutdown = False - hass.bus.listen(EVENT_STATE_CHANGED, self._event_listener) + self._shutdown = False + entry.async_on_unload( + hass.bus.async_listen(EVENT_STATE_CHANGED, self._event_listener) + ) + + def shutdown(self) -> None: + """Shutdown the influx thread.""" + self.queue.put(None) + self.join() + self.influx.close() @callback def _event_listener(self, event): @@ -547,13 +621,13 @@ def get_events_json(self): dropped = 0 with suppress(queue.Empty): - while len(json) < BATCH_BUFFER_SIZE and not self.shutdown: + while len(json) < BATCH_BUFFER_SIZE and not self._shutdown: timeout = None if count == 0 else self.batch_timeout() item = self.queue.get(timeout=timeout) count += 1 if item is None: - self.shutdown = True + self._shutdown = True elif type(item) is tuple: timestamp, event = item age = time.monotonic() - timestamp @@ -596,7 +670,7 @@ def write_to_influxdb(self, json): def run(self): """Process incoming events.""" - while not self.shutdown: + while not self._shutdown: _, json = self.get_events_json() if json: self.write_to_influxdb(json) diff --git a/homeassistant/components/influxdb/config_flow.py b/homeassistant/components/influxdb/config_flow.py new file mode 100644 index 00000000000000..679566e8a8fb50 --- /dev/null +++ b/homeassistant/components/influxdb/config_flow.py @@ -0,0 +1,387 @@ +"""Config flow for InfluxDB integration.""" + +import logging +from pathlib import Path +import shutil +from typing import Any + +import voluptuous as vol +from yarl import URL + +from homeassistant.components.file_upload import process_uploaded_file +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PATH, + CONF_PORT, + CONF_SSL, + CONF_TOKEN, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.selector import ( + FileSelector, + FileSelectorConfig, + TextSelector, + TextSelectorConfig, + TextSelectorType, +) +from homeassistant.helpers.storage import STORAGE_DIR + +from . import DOMAIN, create_influx_url, get_influx_connection +from .const import ( + API_VERSION_2, + CONF_API_VERSION, + CONF_BUCKET, + CONF_DB_NAME, + CONF_ORG, + CONF_SSL_CA_CERT, + DEFAULT_API_VERSION, + DEFAULT_BUCKET, + DEFAULT_DATABASE, + DEFAULT_HOST, + DEFAULT_PORT, + DEFAULT_VERIFY_SSL, +) + +_LOGGER = logging.getLogger(__name__) + +INFLUXDB_V1_SCHEMA = vol.Schema( + { + vol.Required( + CONF_URL, default=f"http://{DEFAULT_HOST}:{DEFAULT_PORT}" + ): TextSelector( + TextSelectorConfig( + type=TextSelectorType.URL, + autocomplete="url", + ), + ), + vol.Required(CONF_VERIFY_SSL, default=False): bool, + vol.Required(CONF_DB_NAME): TextSelector( + TextSelectorConfig( + type=TextSelectorType.TEXT, + ), + ), + vol.Optional(CONF_USERNAME): TextSelector( + TextSelectorConfig( + type=TextSelectorType.TEXT, + autocomplete="username", + ), + ), + vol.Optional(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ), + ), + vol.Optional(CONF_SSL_CA_CERT): FileSelector( + FileSelectorConfig(accept=".pem,.crt,.cer,.der") + ), + } +) + +INFLUXDB_V2_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL, default="https://"): TextSelector( + TextSelectorConfig( + type=TextSelectorType.URL, + autocomplete="url", + ), + ), + vol.Required(CONF_VERIFY_SSL, default=False): bool, + vol.Required(CONF_ORG): TextSelector( + TextSelectorConfig( + type=TextSelectorType.TEXT, + ), + ), + vol.Required(CONF_BUCKET): TextSelector( + TextSelectorConfig( + type=TextSelectorType.TEXT, + ), + ), + vol.Required(CONF_TOKEN): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + ), + ), + vol.Optional(CONF_SSL_CA_CERT): FileSelector( + FileSelectorConfig(accept=".pem,.crt,.cer,.der") + ), + } +) + + +async def _validate_influxdb_connection( + hass: HomeAssistant, data: dict[str, Any] +) -> dict[str, str]: + """Validate connection to influxdb.""" + + def _test_connection() -> None: + influx = get_influx_connection(data, test_write=True) + influx.close() + + errors = {} + + try: + await hass.async_add_executor_job(_test_connection) + except ConnectionError as ex: + _LOGGER.error(ex) + if "SSLError" in ex.args[0]: + errors = {"base": "ssl_error"} + elif "database not found" in ex.args[0]: + errors = {"base": "invalid_database"} + elif "authorization failed" in ex.args[0]: + errors = {"base": "invalid_auth"} + elif "token" in ex.args[0]: + errors = {"base": "invalid_config"} + else: + errors = {"base": "cannot_connect"} + except Exception: + _LOGGER.exception("Unknown error") + errors = {"base": "unknown"} + + return errors + + +async def _save_uploaded_cert_file(hass: HomeAssistant, uploaded_file_id: str) -> Path: + """Move the uploaded file to storage directory.""" + + def _process_upload() -> Path: + with process_uploaded_file(hass, uploaded_file_id) as file_path: + dest_path = Path(hass.config.path(STORAGE_DIR, DOMAIN)) + dest_path.mkdir(exist_ok=True) + file_name = f"influxdb{file_path.suffix}" + dest_file = dest_path / file_name + shutil.move(file_path, dest_file) + return dest_file + + return await hass.async_add_executor_job(_process_upload) + + +class InfluxDBConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for InfluxDB.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Step when user initializes an integration.""" + return self.async_show_menu( + step_id="user", + menu_options=["configure_v1", "configure_v2"], + ) + + async def async_step_configure_v1( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Step when user configures InfluxDB v1.""" + errors: dict[str, str] = {} + + if user_input is not None: + url = URL(user_input[CONF_URL]) + data = { + CONF_API_VERSION: DEFAULT_API_VERSION, + CONF_HOST: url.host, + CONF_PORT: url.port, + CONF_USERNAME: user_input.get(CONF_USERNAME), + CONF_PASSWORD: user_input.get(CONF_PASSWORD), + CONF_DB_NAME: user_input[CONF_DB_NAME], + CONF_SSL: url.scheme == "https", + CONF_PATH: url.path, + CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], + } + if (cert := user_input.get(CONF_SSL_CA_CERT)) is not None: + path = await _save_uploaded_cert_file(self.hass, cert) + data[CONF_SSL_CA_CERT] = str(path) + errors = await _validate_influxdb_connection(self.hass, data) + + if not errors: + title = f"{data[CONF_DB_NAME]} ({data[CONF_HOST]})" + return self.async_create_entry(title=title, data=data) + + schema = INFLUXDB_V1_SCHEMA + + return self.async_show_form( + step_id="configure_v1", + data_schema=self.add_suggested_values_to_schema(schema, user_input), + errors=errors, + ) + + async def async_step_configure_v2( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Step when user configures InfluxDB v2.""" + errors: dict[str, str] = {} + + if user_input is not None: + data = { + CONF_API_VERSION: API_VERSION_2, + CONF_URL: user_input[CONF_URL], + CONF_TOKEN: user_input[CONF_TOKEN], + CONF_ORG: user_input[CONF_ORG], + CONF_BUCKET: user_input[CONF_BUCKET], + CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], + } + if (cert := user_input.get(CONF_SSL_CA_CERT)) is not None: + path = await _save_uploaded_cert_file(self.hass, cert) + data[CONF_SSL_CA_CERT] = str(path) + errors = await _validate_influxdb_connection(self.hass, data) + + if not errors: + title = f"{data[CONF_BUCKET]} ({data[CONF_URL]})" + return self.async_create_entry(title=title, data=data) + + schema = INFLUXDB_V2_SCHEMA + + return self.async_show_form( + step_id="configure_v2", + data_schema=self.add_suggested_values_to_schema(schema, user_input), + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + entry = self._get_reconfigure_entry() + if entry.data[CONF_API_VERSION] == API_VERSION_2: + return await self.async_step_reconfigure_v2(user_input) + return await self.async_step_reconfigure_v1(user_input) + + async def async_step_reconfigure_v1( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of InfluxDB v1.""" + errors: dict[str, str] = {} + entry = self._get_reconfigure_entry() + + if user_input is not None: + url = URL(user_input[CONF_URL]) + data = { + CONF_API_VERSION: DEFAULT_API_VERSION, + CONF_HOST: url.host, + CONF_PORT: url.port, + CONF_USERNAME: user_input.get(CONF_USERNAME), + CONF_PASSWORD: user_input.get(CONF_PASSWORD), + CONF_DB_NAME: user_input[CONF_DB_NAME], + CONF_SSL: url.scheme == "https", + CONF_PATH: url.path, + CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], + } + if (cert := user_input.get(CONF_SSL_CA_CERT)) is not None: + path = await _save_uploaded_cert_file(self.hass, cert) + data[CONF_SSL_CA_CERT] = str(path) + elif CONF_SSL_CA_CERT in entry.data: + data[CONF_SSL_CA_CERT] = entry.data[CONF_SSL_CA_CERT] + errors = await _validate_influxdb_connection(self.hass, data) + + if not errors: + title = f"{data[CONF_DB_NAME]} ({data[CONF_HOST]})" + return self.async_update_reload_and_abort( + entry, title=title, data_updates=data + ) + + suggested_values = dict(entry.data) | (user_input or {}) + if user_input is None: + suggested_values[CONF_URL] = str( + URL.build( + scheme="https" if entry.data.get(CONF_SSL) else "http", + host=entry.data.get(CONF_HOST, ""), + port=entry.data.get(CONF_PORT), + path=entry.data.get(CONF_PATH, ""), + ) + ) + + return self.async_show_form( + step_id="reconfigure_v1", + data_schema=self.add_suggested_values_to_schema( + INFLUXDB_V1_SCHEMA, suggested_values + ), + errors=errors, + ) + + async def async_step_reconfigure_v2( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of InfluxDB v2.""" + errors: dict[str, str] = {} + entry = self._get_reconfigure_entry() + + if user_input is not None: + data = { + CONF_API_VERSION: API_VERSION_2, + CONF_URL: user_input[CONF_URL], + CONF_TOKEN: user_input[CONF_TOKEN], + CONF_ORG: user_input[CONF_ORG], + CONF_BUCKET: user_input[CONF_BUCKET], + CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], + } + if (cert := user_input.get(CONF_SSL_CA_CERT)) is not None: + path = await _save_uploaded_cert_file(self.hass, cert) + data[CONF_SSL_CA_CERT] = str(path) + elif CONF_SSL_CA_CERT in entry.data: + data[CONF_SSL_CA_CERT] = entry.data[CONF_SSL_CA_CERT] + errors = await _validate_influxdb_connection(self.hass, data) + + if not errors: + title = f"{data[CONF_BUCKET]} ({data[CONF_URL]})" + return self.async_update_reload_and_abort( + entry, title=title, data_updates=data + ) + + return self.async_show_form( + step_id="reconfigure_v2", + data_schema=self.add_suggested_values_to_schema( + INFLUXDB_V2_SCHEMA, entry.data | (user_input or {}) + ), + errors=errors, + ) + + async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: + """Handle the initial step.""" + import_data = {**import_data} + import_data.setdefault(CONF_API_VERSION, DEFAULT_API_VERSION) + import_data.setdefault(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL) + import_data.setdefault(CONF_DB_NAME, DEFAULT_DATABASE) + import_data.setdefault(CONF_BUCKET, DEFAULT_BUCKET) + + api_version = import_data[CONF_API_VERSION] + + if api_version == DEFAULT_API_VERSION: + host = import_data.get(CONF_HOST, DEFAULT_HOST) + database = import_data[CONF_DB_NAME] + title = f"{database} ({host})" + data = { + CONF_API_VERSION: api_version, + CONF_HOST: host, + CONF_PORT: import_data.get(CONF_PORT), + CONF_USERNAME: import_data.get(CONF_USERNAME), + CONF_PASSWORD: import_data.get(CONF_PASSWORD), + CONF_DB_NAME: database, + CONF_SSL: import_data.get(CONF_SSL), + CONF_PATH: import_data.get(CONF_PATH), + CONF_VERIFY_SSL: import_data[CONF_VERIFY_SSL], + CONF_SSL_CA_CERT: import_data.get(CONF_SSL_CA_CERT), + } + else: + create_influx_url(import_data) # Only modifies dict for api_version == 2 + bucket = import_data[CONF_BUCKET] + url = import_data.get(CONF_URL) + title = f"{bucket} ({url})" + data = { + CONF_API_VERSION: api_version, + CONF_URL: url, + CONF_TOKEN: import_data.get(CONF_TOKEN), + CONF_ORG: import_data.get(CONF_ORG), + CONF_BUCKET: bucket, + CONF_VERIFY_SSL: import_data[CONF_VERIFY_SSL], + CONF_SSL_CA_CERT: import_data.get(CONF_SSL_CA_CERT), + } + + errors = await _validate_influxdb_connection(self.hass, data) + if errors: + return self.async_abort(reason=errors["base"]) + + return self.async_create_entry(title=title, data=data) diff --git a/homeassistant/components/influxdb/const.py b/homeassistant/components/influxdb/const.py index 78cb7908eecba0..cb3a45be38e819 100644 --- a/homeassistant/components/influxdb/const.py +++ b/homeassistant/components/influxdb/const.py @@ -48,7 +48,9 @@ CONF_IMPORTS = "imports" DEFAULT_DATABASE = "home_assistant" +DEFAULT_HOST = "localhost" DEFAULT_HOST_V2 = "us-west-2-1.aws.cloud2.influxdata.com" +DEFAULT_PORT = 8086 DEFAULT_SSL_V2 = True DEFAULT_BUCKET = "Home Assistant" DEFAULT_VERIFY_SSL = True @@ -130,8 +132,8 @@ RENDERING_WHERE_MESSAGE = "Rendering where: %s." RENDERING_WHERE_ERROR_MESSAGE = "Could not render where template: %s." + COMPONENT_CONFIG_SCHEMA_CONNECTION = { - # Connection config for V1 and V2 APIs. vol.Optional(CONF_API_VERSION, default=DEFAULT_API_VERSION): vol.All( vol.Coerce(str), vol.In([DEFAULT_API_VERSION, API_VERSION_2]), @@ -152,3 +154,14 @@ vol.Inclusive(CONF_ORG, "v2_authentication"): cv.string, vol.Optional(CONF_BUCKET, default=DEFAULT_BUCKET): cv.string, } + +# Same keys without defaults, used in CONFIG_SCHEMA to validate +# without injecting default values (so we can detect explicit keys). +COMPONENT_CONFIG_SCHEMA_CONNECTION_VALIDATORS = { + ( + vol.Optional(k.schema) + if isinstance(k, vol.Optional) and k.default is not vol.UNDEFINED + else k + ): v + for k, v in COMPONENT_CONFIG_SCHEMA_CONNECTION.items() +} diff --git a/homeassistant/components/influxdb/issue.py b/homeassistant/components/influxdb/issue.py new file mode 100644 index 00000000000000..3f9c85ef876b22 --- /dev/null +++ b/homeassistant/components/influxdb/issue.py @@ -0,0 +1,34 @@ +"""Issues for InfluxDB integration.""" + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import DOMAIN + + +@callback +def async_create_deprecated_yaml_issue( + hass: HomeAssistant, *, error: str | None = None +) -> None: + """Create a repair issue for deprecated YAML connection configuration.""" + if error is None: + issue_id = "deprecated_yaml" + severity = IssueSeverity.WARNING + else: + issue_id = f"deprecated_yaml_import_issue_{error}" + severity = IssueSeverity.ERROR + + async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=False, + issue_domain=DOMAIN, + breaks_in_ha_version="2026.9.0", + severity=severity, + translation_key=issue_id, + translation_placeholders={ + "domain": DOMAIN, + "url": f"/config/integrations/dashboard/add?domain={DOMAIN}", + }, + ) diff --git a/homeassistant/components/influxdb/manifest.json b/homeassistant/components/influxdb/manifest.json index 40514e355e479b..a048b5dca4fca4 100644 --- a/homeassistant/components/influxdb/manifest.json +++ b/homeassistant/components/influxdb/manifest.json @@ -1,10 +1,12 @@ { "domain": "influxdb", "name": "InfluxDB", - "codeowners": ["@mdegat01"], + "codeowners": ["@mdegat01", "@Robbie1221"], + "config_flow": true, + "dependencies": ["file_upload"], "documentation": "https://www.home-assistant.io/integrations/influxdb", "iot_class": "local_push", "loggers": ["influxdb", "influxdb_client"], - "quality_scale": "legacy", - "requirements": ["influxdb==5.3.1", "influxdb-client==1.50.0"] + "requirements": ["influxdb==5.3.1", "influxdb-client==1.50.0"], + "single_config_entry": true } diff --git a/homeassistant/components/influxdb/strings.json b/homeassistant/components/influxdb/strings.json new file mode 100644 index 00000000000000..18a7966fb51cfe --- /dev/null +++ b/homeassistant/components/influxdb/strings.json @@ -0,0 +1,120 @@ +{ + "common": { + "ssl_ca_cert": "SSL CA certificate (Optional)" + }, + "config": { + "abort": { + "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%]", + "invalid_config": "Invalid organization, bucket or token", + "invalid_database": "Invalid database", + "ssl_error": "SSL certificate error", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "configure_v1": { + "data": { + "database": "Database", + "password": "[%key:common::config_flow::data::password%]", + "ssl_ca_cert": "[%key:component::influxdb::common::ssl_ca_cert%]", + "url": "[%key:common::config_flow::data::url%]", + "username": "[%key:common::config_flow::data::username%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "database": "The name of the database.", + "ssl_ca_cert": "Path to the SSL certificate" + }, + "title": "InfluxDB configuration" + }, + "configure_v2": { + "data": { + "bucket": "Bucket", + "organization": "Organization", + "ssl_ca_cert": "[%key:component::influxdb::common::ssl_ca_cert%]", + "token": "[%key:common::config_flow::data::api_token%]", + "url": "[%key:common::config_flow::data::url%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "bucket": "The name of the bucket.", + "organization": "The name of the organization.", + "ssl_ca_cert": "Path to the SSL certificate" + }, + "title": "InfluxDB configuration" + }, + "import": { + "title": "Import configuration" + }, + "reconfigure_v1": { + "data": { + "database": "[%key:component::influxdb::config::step::configure_v1::data::database%]", + "password": "[%key:common::config_flow::data::password%]", + "ssl_ca_cert": "[%key:component::influxdb::common::ssl_ca_cert%]", + "url": "[%key:common::config_flow::data::url%]", + "username": "[%key:common::config_flow::data::username%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "database": "[%key:component::influxdb::config::step::configure_v1::data_description::database%]", + "ssl_ca_cert": "[%key:component::influxdb::config::step::configure_v1::data_description::ssl_ca_cert%]" + }, + "description": "Update the connection settings for your InfluxDB v1.x server.", + "title": "[%key:component::influxdb::config::step::configure_v1::title%]" + }, + "reconfigure_v2": { + "data": { + "bucket": "[%key:component::influxdb::config::step::configure_v2::data::bucket%]", + "organization": "[%key:component::influxdb::config::step::configure_v2::data::organization%]", + "ssl_ca_cert": "[%key:component::influxdb::common::ssl_ca_cert%]", + "token": "[%key:common::config_flow::data::api_token%]", + "url": "[%key:common::config_flow::data::url%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "bucket": "[%key:component::influxdb::config::step::configure_v2::data_description::bucket%]", + "organization": "[%key:component::influxdb::config::step::configure_v2::data_description::organization%]", + "ssl_ca_cert": "[%key:component::influxdb::config::step::configure_v2::data_description::ssl_ca_cert%]" + }, + "description": "Update the connection settings for your InfluxDB v2.x / v3 server.", + "title": "[%key:component::influxdb::config::step::configure_v2::title%]" + }, + "user": { + "menu_options": { + "configure_v1": "InfluxDB v1.x", + "configure_v2": "InfluxDB v2.x / v3" + }, + "title": "Choose InfluxDB version" + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "Configuring InfluxDB connection settings using YAML is being removed. Your existing YAML connection configuration has been imported into the UI automatically.\n\nRemove the `{domain}` connection and authentication keys from your `configuration.yaml` file and restart Home Assistant to fix this issue. Other options like `include`, `exclude`, and `tags` remain in YAML for now. \n\nThe following keys should be removed:\n- `api_version`\n- `host`\n- `port`\n- `ssl`\n- `verify_ssl`\n- `ssl_ca_cert`\n- `username`\n- `password`\n- `database`\n- `token`\n- `organization`\n- `bucket`\n- `path`", + "title": "The InfluxDB YAML configuration is being removed" + }, + "deprecated_yaml_import_issue_cannot_connect": { + "description": "Configuring InfluxDB connection settings using YAML is being removed but the import failed because Home Assistant could not connect to the InfluxDB server.\n\nPlease correct your YAML configuration and restart Home Assistant.\n\nAlternatively you can remove the `{domain}` connection and authentication keys from your `configuration.yaml` file and continue to [set up the integration]({url}) manually. \n\nThe following keys should be removed:\n- `api_version`\n- `host`\n- `port`\n- `ssl`\n- `verify_ssl`\n- `ssl_ca_cert`\n- `username`\n- `password`\n- `database`\n- `token`\n- `organization`\n- `bucket`\n- `path`", + "title": "Failed to import InfluxDB YAML configuration" + }, + "deprecated_yaml_import_issue_invalid_auth": { + "description": "Configuring InfluxDB connection settings using YAML is being removed but the import failed because the provided credentials are invalid.\n\nPlease correct your YAML configuration and restart Home Assistant.\n\nAlternatively you can remove the `{domain}` connection and authentication keys from your `configuration.yaml` file and continue to [set up the integration]({url}) manually. \n\nThe following keys should be removed:\n- `api_version`\n- `host`\n- `port`\n- `ssl`\n- `verify_ssl`\n- `ssl_ca_cert`\n- `username`\n- `password`\n- `database`\n- `token`\n- `organization`\n- `bucket`\n- `path`", + "title": "[%key:component::influxdb::issues::deprecated_yaml_import_issue_cannot_connect::title%]" + }, + "deprecated_yaml_import_issue_invalid_database": { + "description": "Configuring InfluxDB connection settings using YAML is being removed but the import failed because the specified database was not found.\n\nPlease correct your YAML configuration and restart Home Assistant.\n\nAlternatively you can remove the `{domain}` connection and authentication keys from your `configuration.yaml` file and continue to [set up the integration]({url}) manually. \n\nThe following keys should be removed:\n- `api_version`\n- `host`\n- `port`\n- `ssl`\n- `verify_ssl`\n- `ssl_ca_cert`\n- `username`\n- `password`\n- `database`\n- `token`\n- `organization`\n- `bucket`\n- `path`", + "title": "[%key:component::influxdb::issues::deprecated_yaml_import_issue_cannot_connect::title%]" + }, + "deprecated_yaml_import_issue_ssl_error": { + "description": "Configuring InfluxDB connection settings using YAML is being removed but the import failed due to an SSL certificate error.\n\nPlease correct your YAML configuration and restart Home Assistant.\n\nAlternatively you can remove the `{domain}` connection and authentication keys from your `configuration.yaml` file and continue to [set up the integration]({url}) manually. \n\nThe following keys should be removed:\n- `api_version`\n- `host`\n- `port`\n- `ssl`\n- `verify_ssl`\n- `ssl_ca_cert`\n- `username`\n- `password`\n- `database`\n- `token`\n- `organization`\n- `bucket`\n- `path`", + "title": "[%key:component::influxdb::issues::deprecated_yaml_import_issue_cannot_connect::title%]" + }, + "deprecated_yaml_import_issue_unknown": { + "description": "Configuring InfluxDB connection settings using YAML is being removed but the import failed due to an unknown error.\n\nPlease correct your YAML configuration and restart Home Assistant.\n\nAlternatively you can remove the `{domain}` connection and authentication keys from your `configuration.yaml` file and continue to [set up the integration]({url}) manually. \n\nThe following keys should be removed:\n- `api_version`\n- `host`\n- `port`\n- `ssl`\n- `verify_ssl`\n- `ssl_ca_cert`\n- `username`\n- `password`\n- `database`\n- `token`\n- `organization`\n- `bucket`\n- `path`", + "title": "[%key:component::influxdb::issues::deprecated_yaml_import_issue_cannot_connect::title%]" + } + } +} diff --git a/homeassistant/components/infrared/__init__.py b/homeassistant/components/infrared/__init__.py new file mode 100644 index 00000000000000..6411fe9599a660 --- /dev/null +++ b/homeassistant/components/infrared/__init__.py @@ -0,0 +1,153 @@ +"""Provides functionality to interact with infrared devices.""" + +from __future__ import annotations + +from abc import abstractmethod +from datetime import timedelta +import logging +from typing import final + +from infrared_protocols import Command as InfraredCommand + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.entity import EntityDescription +from homeassistant.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", + "InfraredEntity", + "InfraredEntityDescription", + "async_get_emitters", + "async_send_command", +] + +_LOGGER = logging.getLogger(__name__) + +DATA_COMPONENT: HassKey[EntityComponent[InfraredEntity]] = HassKey(DOMAIN) +ENTITY_ID_FORMAT = DOMAIN + ".{}" +PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA +PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE +SCAN_INTERVAL = timedelta(seconds=30) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the infrared domain.""" + component = hass.data[DATA_COMPONENT] = EntityComponent[InfraredEntity]( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + 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_emitters(hass: HomeAssistant) -> list[InfraredEntity]: + """Get all infrared emitters.""" + component = hass.data.get(DATA_COMPONENT) + if component is None: + return [] + + return list(component.entities) + + +async def async_send_command( + hass: HomeAssistant, + entity_id_or_uuid: str, + command: InfraredCommand, + context: Context | None = None, +) -> None: + """Send an IR command to the specified infrared entity. + + Raises: + HomeAssistantError: If the infrared entity is not found. + """ + component = hass.data.get(DATA_COMPONENT) + if component is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="component_not_loaded", + ) + + ent_reg = er.async_get(hass) + entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid) + entity = component.get_entity(entity_id) + if entity is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="entity_not_found", + translation_placeholders={"entity_id": entity_id}, + ) + + if context is not None: + entity.async_set_context(context) + + await entity.async_send_command_internal(command) + + +class InfraredEntityDescription(EntityDescription, frozen_or_thawed=True): + """Describes infrared entities.""" + + +class InfraredEntity(RestoreEntity): + """Base class for infrared transmitter entities.""" + + entity_description: InfraredEntityDescription + _attr_should_poll = False + _attr_state: None = None + + __last_command_sent: str | None = None + + @property + @final + def state(self) -> str | None: + """Return the entity state.""" + return self.__last_command_sent + + @final + async def async_send_command_internal(self, command: InfraredCommand) -> None: + """Send an IR command and update state. + + Should not be overridden, handles setting last sent timestamp. + """ + await self.async_send_command(command) + self.__last_command_sent = dt_util.utcnow().isoformat(timespec="milliseconds") + self.async_write_ha_state() + + @final + async def async_internal_added_to_hass(self) -> None: + """Call when the infrared entity is added to hass.""" + await super().async_internal_added_to_hass() + state = await self.async_get_last_state() + if state is not None and state.state not in (STATE_UNAVAILABLE, None): + self.__last_command_sent = state.state + + @abstractmethod + async def async_send_command(self, command: InfraredCommand) -> None: + """Send an IR command. + + Args: + command: The IR command to send. + + Raises: + HomeAssistantError: If transmission fails. + """ diff --git a/homeassistant/components/infrared/const.py b/homeassistant/components/infrared/const.py new file mode 100644 index 00000000000000..2240607f52a8ec --- /dev/null +++ b/homeassistant/components/infrared/const.py @@ -0,0 +1,5 @@ +"""Constants for the Infrared integration.""" + +from typing import Final + +DOMAIN: Final = "infrared" diff --git a/homeassistant/components/infrared/icons.json b/homeassistant/components/infrared/icons.json new file mode 100644 index 00000000000000..3a12eb7d0b5025 --- /dev/null +++ b/homeassistant/components/infrared/icons.json @@ -0,0 +1,7 @@ +{ + "entity_component": { + "_": { + "default": "mdi:led-on" + } + } +} diff --git a/homeassistant/components/infrared/manifest.json b/homeassistant/components/infrared/manifest.json new file mode 100644 index 00000000000000..49cf9ad98df38a --- /dev/null +++ b/homeassistant/components/infrared/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "infrared", + "name": "Infrared", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/infrared", + "integration_type": "entity", + "quality_scale": "internal", + "requirements": ["infrared-protocols==1.0.0"] +} diff --git a/homeassistant/components/infrared/strings.json b/homeassistant/components/infrared/strings.json new file mode 100644 index 00000000000000..c4cf75cf1f3cb9 --- /dev/null +++ b/homeassistant/components/infrared/strings.json @@ -0,0 +1,10 @@ +{ + "exceptions": { + "component_not_loaded": { + "message": "Infrared component not loaded" + }, + "entity_not_found": { + "message": "Infrared entity `{entity_id}` not found" + } + } +} diff --git a/homeassistant/components/input_boolean/__init__.py b/homeassistant/components/input_boolean/__init__.py index a0a7514eaaf01c..5fd500848958ab 100644 --- a/homeassistant/components/input_boolean/__init__.py +++ b/homeassistant/components/input_boolean/__init__.py @@ -120,8 +120,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def reload_service_handler(service_call: ServiceCall) -> None: """Remove all input booleans and load new ones from config.""" conf = await component.async_prepare_reload(skip_reset=True) - if conf is None: - return await yaml_collection.async_load( [ {CONF_ID: id_, **(conf or {})} diff --git a/homeassistant/components/input_boolean/icons.json b/homeassistant/components/input_boolean/icons.json index bf65c2d8d7a807..88ff1eb50b0db9 100644 --- a/homeassistant/components/input_boolean/icons.json +++ b/homeassistant/components/input_boolean/icons.json @@ -20,5 +20,13 @@ "turn_on": { "service": "mdi:toggle-switch" } + }, + "triggers": { + "turned_off": { + "trigger": "mdi:toggle-switch-off" + }, + "turned_on": { + "trigger": "mdi:toggle-switch" + } } } diff --git a/homeassistant/components/input_boolean/strings.json b/homeassistant/components/input_boolean/strings.json index 030cfc456d6f4b..297af52fdb1225 100644 --- a/homeassistant/components/input_boolean/strings.json +++ b/homeassistant/components/input_boolean/strings.json @@ -1,4 +1,8 @@ { + "common": { + "trigger_behavior_description": "The behavior of the targeted toggles to trigger on.", + "trigger_behavior_name": "Behavior" + }, "entity_component": { "_": { "name": "[%key:component::input_boolean::title%]", @@ -17,6 +21,15 @@ } } }, + "selector": { + "trigger_behavior": { + "options": { + "any": "Any", + "first": "First", + "last": "Last" + } + } + }, "services": { "reload": { "description": "Reloads helpers from the YAML-configuration.", @@ -35,5 +48,27 @@ "name": "[%key:common::action::turn_on%]" } }, - "title": "Input boolean" + "title": "Input boolean", + "triggers": { + "turned_off": { + "description": "Triggers after one or more toggles turn off.", + "fields": { + "behavior": { + "description": "[%key:component::input_boolean::common::trigger_behavior_description%]", + "name": "[%key:component::input_boolean::common::trigger_behavior_name%]" + } + }, + "name": "Toggle turned off" + }, + "turned_on": { + "description": "Triggers after one or more toggles turn on.", + "fields": { + "behavior": { + "description": "[%key:component::input_boolean::common::trigger_behavior_description%]", + "name": "[%key:component::input_boolean::common::trigger_behavior_name%]" + } + }, + "name": "Toggle turned on" + } + } } diff --git a/homeassistant/components/input_boolean/trigger.py b/homeassistant/components/input_boolean/trigger.py new file mode 100644 index 00000000000000..64baeb3f472975 --- /dev/null +++ b/homeassistant/components/input_boolean/trigger.py @@ -0,0 +1,17 @@ +"""Provides triggers for input booleans.""" + +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger + +from . import DOMAIN + +TRIGGERS: dict[str, type[Trigger]] = { + "turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON), + "turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for input booleans.""" + return TRIGGERS diff --git a/homeassistant/components/input_boolean/triggers.yaml b/homeassistant/components/input_boolean/triggers.yaml new file mode 100644 index 00000000000000..c892c75a13df63 --- /dev/null +++ b/homeassistant/components/input_boolean/triggers.yaml @@ -0,0 +1,18 @@ +.trigger_common: &trigger_common + target: + entity: + domain: input_boolean + fields: + behavior: + required: true + default: any + selector: + select: + options: + - first + - last + - any + translation_key: trigger_behavior + +turned_off: *trigger_common +turned_on: *trigger_common diff --git a/homeassistant/components/input_button/__init__.py b/homeassistant/components/input_button/__init__.py index 12bc98f7674ffd..6bf7dc9d6bff1b 100644 --- a/homeassistant/components/input_button/__init__.py +++ b/homeassistant/components/input_button/__init__.py @@ -105,8 +105,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def reload_service_handler(service_call: ServiceCall) -> None: """Remove all input buttons and load new ones from config.""" conf = await component.async_prepare_reload(skip_reset=True) - if conf is None: - return await yaml_collection.async_load( [ {CONF_ID: id_, **(conf or {})} diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py index 60f882c2726862..fb7394902331db 100644 --- a/homeassistant/components/input_datetime/__init__.py +++ b/homeassistant/components/input_datetime/__init__.py @@ -158,8 +158,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def reload_service_handler(service_call: ServiceCall) -> None: """Reload yaml entities.""" conf = await component.async_prepare_reload(skip_reset=True) - if conf is None: - conf = {DOMAIN: {}} await yaml_collection.async_load( [{CONF_ID: id_, **cfg} for id_, cfg in conf.get(DOMAIN, {}).items()] ) @@ -312,7 +310,7 @@ def has_time(self) -> bool: return self._config[CONF_HAS_TIME] @property - def icon(self): + def icon(self) -> str | None: """Return the icon to be used for this entity.""" return self._config.get(CONF_ICON) @@ -339,9 +337,9 @@ def capability_attributes(self) -> dict[str, Any]: } @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - attrs = { + attrs: dict[str, Any] = { ATTR_EDITABLE: self.editable, } diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py index 3352b55442ac25..81d1479be03b6c 100644 --- a/homeassistant/components/input_number/__init__.py +++ b/homeassistant/components/input_number/__init__.py @@ -4,7 +4,7 @@ from contextlib import suppress import logging -from typing import Self +from typing import Any, Self import voluptuous as vol @@ -136,8 +136,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def reload_service_handler(service_call: ServiceCall) -> None: """Reload yaml entities.""" conf = await component.async_prepare_reload(skip_reset=True) - if conf is None: - conf = {DOMAIN: {}} await yaml_collection.async_load( [{CONF_ID: id_, **conf} for id_, conf in conf.get(DOMAIN, {}).items()] ) @@ -245,7 +243,7 @@ def name(self): return self._config.get(CONF_NAME) @property - def icon(self): + def icon(self) -> str | None: """Return the icon to be used for this entity.""" return self._config.get(CONF_ICON) @@ -270,7 +268,7 @@ def unique_id(self) -> str | None: return self._config[CONF_ID] @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return { ATTR_INITIAL: self._config.get(CONF_INITIAL), diff --git a/homeassistant/components/input_select/__init__.py b/homeassistant/components/input_select/__init__.py index 171998c02bc61f..b05509ea09e5db 100644 --- a/homeassistant/components/input_select/__init__.py +++ b/homeassistant/components/input_select/__init__.py @@ -166,8 +166,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def reload_service_handler(service_call: ServiceCall) -> None: """Reload yaml entities.""" conf = await component.async_prepare_reload(skip_reset=True) - if conf is None: - conf = {DOMAIN: {}} await yaml_collection.async_load( [{CONF_ID: id_, **cfg} for id_, cfg in conf.get(DOMAIN, {}).items()] ) diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py index 4928b4325d1ac8..9945f1dcc3afe9 100644 --- a/homeassistant/components/input_text/__init__.py +++ b/homeassistant/components/input_text/__init__.py @@ -145,8 +145,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def reload_service_handler(service_call: ServiceCall) -> None: """Reload yaml entities.""" conf = await component.async_prepare_reload(skip_reset=True) - if conf is None: - conf = {DOMAIN: {}} await yaml_collection.async_load( [{CONF_ID: id_, **(cfg or {})} for id_, cfg in conf.get(DOMAIN, {}).items()] ) diff --git a/homeassistant/components/insteon/binary_sensor.py b/homeassistant/components/insteon/binary_sensor.py index 887c8fb64a3b1f..2e0092c5a0c6f0 100644 --- a/homeassistant/components/insteon/binary_sensor.py +++ b/homeassistant/components/insteon/binary_sensor.py @@ -80,6 +80,6 @@ def __init__(self, device, group): self._attr_device_class = SENSOR_TYPES.get(self._insteon_device_group.name) @property - def is_on(self): + def is_on(self) -> bool: """Return the boolean response if the node is on.""" return bool(self._insteon_device_group.value) diff --git a/homeassistant/components/insteon/climate.py b/homeassistant/components/insteon/climate.py index eb33e3ab88c16a..e26d30d5cdd049 100644 --- a/homeassistant/components/insteon/climate.py +++ b/homeassistant/components/insteon/climate.py @@ -168,7 +168,7 @@ def hvac_action(self) -> HVACAction: return HVACAction.IDLE @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Provide attributes for display on device card.""" attr = super().extra_state_attributes humidifier = "off" diff --git a/homeassistant/components/insteon/entity.py b/homeassistant/components/insteon/entity.py index 0b2bbbf9e2e712..894596b6a063e2 100644 --- a/homeassistant/components/insteon/entity.py +++ b/homeassistant/components/insteon/entity.py @@ -2,6 +2,7 @@ import functools import logging +from typing import Any from pyinsteon import devices @@ -72,7 +73,7 @@ def name(self): return f"{description} {self._insteon_device.address}{extension}" @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Provide attributes for display on device card.""" return { "insteon_address": self.address, diff --git a/homeassistant/components/insteon/light.py b/homeassistant/components/insteon/light.py index e4f09fe56894db..c617f7c55926d2 100644 --- a/homeassistant/components/insteon/light.py +++ b/homeassistant/components/insteon/light.py @@ -61,7 +61,7 @@ def __init__(self, device: InsteonDevice, group: int) -> None: self._attr_supported_color_modes = {ColorMode.ONOFF} @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" return self._insteon_device_group.value diff --git a/homeassistant/components/insteon/switch.py b/homeassistant/components/insteon/switch.py index e3f7cf3d7a935b..5294af8be1a9d3 100644 --- a/homeassistant/components/insteon/switch.py +++ b/homeassistant/components/insteon/switch.py @@ -46,7 +46,7 @@ class InsteonSwitchEntity(InsteonEntity, SwitchEntity): """A Class for an Insteon switch entity.""" @property - def is_on(self): + def is_on(self) -> bool: """Return the boolean response if the node is on.""" return bool(self._insteon_device_group.value) diff --git a/homeassistant/components/intelliclima/__init__.py b/homeassistant/components/intelliclima/__init__.py index 9d8b33004de90c..22ab24369e15aa 100644 --- a/homeassistant/components/intelliclima/__init__.py +++ b/homeassistant/components/intelliclima/__init__.py @@ -9,7 +9,7 @@ from .const import LOGGER from .coordinator import IntelliClimaConfigEntry, IntelliClimaCoordinator -PLATFORMS = [Platform.FAN] +PLATFORMS = [Platform.FAN, Platform.SELECT, Platform.SENSOR] async def async_setup_entry( diff --git a/homeassistant/components/intelliclima/entity.py b/homeassistant/components/intelliclima/entity.py index 64cffbf2470cbb..059628ac21473b 100644 --- a/homeassistant/components/intelliclima/entity.py +++ b/homeassistant/components/intelliclima/entity.py @@ -27,8 +27,6 @@ def __init__( """Class initializer.""" super().__init__(coordinator=coordinator) - self._attr_unique_id = device.id - # Make this HA "device" use the IntelliClima device name. self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device.id)}, diff --git a/homeassistant/components/intelliclima/fan.py b/homeassistant/components/intelliclima/fan.py index c00bf2a8f2ec2d..28b64e1d7687d1 100644 --- a/homeassistant/components/intelliclima/fan.py +++ b/homeassistant/components/intelliclima/fan.py @@ -62,6 +62,7 @@ def __init__( super().__init__(coordinator, device) self._speed_range = (int(FanSpeed.sleep), int(FanSpeed.high)) + self._attr_unique_id = device.id @property def is_on(self) -> bool: @@ -73,7 +74,7 @@ def percentage(self) -> int | None: """Return the current speed percentage.""" device_data = self._device_data - if device_data.speed_set == FanSpeed.auto: + if device_data.speed_set == FanSpeed.auto_get: return None return ranged_value_to_percentage(self._speed_range, int(device_data.speed_set)) @@ -91,7 +92,7 @@ def preset_mode(self) -> str | None: if device_data.mode_set == FanMode.off: return None if ( - device_data.speed_set == FanSpeed.auto + device_data.speed_set == FanSpeed.auto_get and device_data.mode_set == FanMode.sensor ): return "auto" @@ -110,7 +111,7 @@ async def async_turn_on( infinitely. """ percentage = 25 if percentage == 0 else percentage - await self.async_set_mode_speed(fan_mode=preset_mode, percentage=percentage) + await self.async_set_mode_speed(preset_mode=preset_mode, percentage=percentage) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the fan.""" @@ -123,10 +124,10 @@ async def async_set_percentage(self, percentage: int) -> None: async def async_set_preset_mode(self, preset_mode: str) -> None: """Set preset mode.""" - await self.async_set_mode_speed(fan_mode=preset_mode) + await self.async_set_mode_speed(preset_mode=preset_mode) async def async_set_mode_speed( - self, fan_mode: str | None = None, percentage: int | None = None + self, preset_mode: str | None = None, percentage: int | None = None ) -> None: """Set mode and speed. @@ -136,7 +137,7 @@ async def async_set_mode_speed( percentage = self.percentage if percentage is None else percentage percentage = 25 if percentage is None else percentage - if fan_mode == "auto": + if preset_mode == "auto": # auto is a special case with special mode and speed setting await self.coordinator.api.ecocomfort.set_mode_speed_auto(self._device_sn) await self.coordinator.async_request_refresh() @@ -147,21 +148,20 @@ async def async_set_mode_speed( return # Determine the fan mode - if fan_mode is not None: - # Set to requested fan_mode - mode = fan_mode - elif not self.is_on: + if not self.is_on: # Default to alternate fan mode if not turned on mode = FanMode.alternate else: # Maintain current mode mode = self._device_data.mode_set - speed = str( - math.ceil( - percentage_to_ranged_value( - self._speed_range, - percentage, + speed = FanSpeed( + str( + math.ceil( + percentage_to_ranged_value( + self._speed_range, + percentage, + ) ) ) ) diff --git a/homeassistant/components/intelliclima/manifest.json b/homeassistant/components/intelliclima/manifest.json index 4d15d4bfa9ad6f..97c90f65e96ea7 100644 --- a/homeassistant/components/intelliclima/manifest.json +++ b/homeassistant/components/intelliclima/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["pyintelliclima==0.2.2"] + "requirements": ["pyintelliclima==0.3.1"] } diff --git a/homeassistant/components/intelliclima/quality_scale.yaml b/homeassistant/components/intelliclima/quality_scale.yaml index f2164cc97bc3a1..e66578de0633a7 100644 --- a/homeassistant/components/intelliclima/quality_scale.yaml +++ b/homeassistant/components/intelliclima/quality_scale.yaml @@ -49,7 +49,7 @@ rules: comment: | Unclear if discovery is possible. docs-data-update: done - docs-examples: todo + docs-examples: done docs-known-limitations: done docs-supported-devices: done docs-supported-functions: done diff --git a/homeassistant/components/intelliclima/select.py b/homeassistant/components/intelliclima/select.py new file mode 100644 index 00000000000000..02e865088fab3f --- /dev/null +++ b/homeassistant/components/intelliclima/select.py @@ -0,0 +1,96 @@ +"""Select platform for IntelliClima VMC.""" + +from pyintelliclima.const import FanMode, FanSpeed +from pyintelliclima.intelliclima_types import IntelliClimaECO + +from homeassistant.components.select import SelectEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import IntelliClimaConfigEntry, IntelliClimaCoordinator +from .entity import IntelliClimaECOEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +FAN_MODE_TO_INTELLICLIMA_MODE = { + "forward": FanMode.inward, + "reverse": FanMode.outward, + "alternate": FanMode.alternate, + "sensor": FanMode.sensor, +} +INTELLICLIMA_MODE_TO_FAN_MODE = {v: k for k, v in FAN_MODE_TO_INTELLICLIMA_MODE.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IntelliClimaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up IntelliClima VMC fan mode select.""" + coordinator = entry.runtime_data + + entities: list[IntelliClimaVMCFanModeSelect] = [ + IntelliClimaVMCFanModeSelect( + coordinator=coordinator, + device=ecocomfort2, + ) + for ecocomfort2 in coordinator.data.ecocomfort2_devices.values() + ] + + async_add_entities(entities) + + +class IntelliClimaVMCFanModeSelect(IntelliClimaECOEntity, SelectEntity): + """Representation of an IntelliClima VMC fan mode selector.""" + + _attr_translation_key = "fan_mode" + _attr_options = ["forward", "reverse", "alternate", "sensor"] + + def __init__( + self, + coordinator: IntelliClimaCoordinator, + device: IntelliClimaECO, + ) -> None: + """Class initializer.""" + super().__init__(coordinator, device) + + self._attr_unique_id = f"{device.id}_fan_mode" + + @property + def current_option(self) -> str | None: + """Return the current fan mode.""" + device_data = self._device_data + + if device_data.mode_set == FanMode.off: + return None + + # If in auto mode (sensor mode with auto speed), return None (handled by fan entity preset mode) + if ( + device_data.speed_set == FanSpeed.auto_get + and device_data.mode_set == FanMode.sensor + ): + return None + + return INTELLICLIMA_MODE_TO_FAN_MODE.get(device_data.mode_set) + + async def async_select_option(self, option: str) -> None: + """Set the fan mode.""" + device_data = self._device_data + + mode = FAN_MODE_TO_INTELLICLIMA_MODE[option] + + # Determine speed: keep current speed if available, otherwise default to sleep + if ( + device_data.speed_set == FanSpeed.auto_get + or device_data.mode_set == FanMode.off + ): + speed = FanSpeed.sleep + else: + speed = device_data.speed_set + + await self.coordinator.api.ecocomfort.set_mode_speed( + self._device_sn, mode, speed + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/intelliclima/sensor.py b/homeassistant/components/intelliclima/sensor.py new file mode 100644 index 00000000000000..db7285e844a4a4 --- /dev/null +++ b/homeassistant/components/intelliclima/sensor.py @@ -0,0 +1,101 @@ +"""Sensor platform for IntelliClima VMC.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from pyintelliclima.intelliclima_types import IntelliClimaECO + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import IntelliClimaConfigEntry, IntelliClimaCoordinator +from .entity import IntelliClimaECOEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class IntelliClimaSensorEntityDescription(SensorEntityDescription): + """Describes a sensor entity.""" + + value_fn: Callable[[IntelliClimaECO], int | float | str | None] + + +INTELLICLIMA_SENSORS: tuple[IntelliClimaSensorEntityDescription, ...] = ( + IntelliClimaSensorEntityDescription( + key="temperature", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda device_data: float(device_data.tamb), + ), + IntelliClimaSensorEntityDescription( + key="humidity", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda device_data: float(device_data.rh), + ), + IntelliClimaSensorEntityDescription( + key="voc", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + value_fn=lambda device_data: float(device_data.voc_state), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IntelliClimaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up a IntelliClima Sensors.""" + coordinator = entry.runtime_data + + entities: list[IntelliClimaSensor] = [ + IntelliClimaSensor( + coordinator=coordinator, device=ecocomfort2, description=description + ) + for ecocomfort2 in coordinator.data.ecocomfort2_devices.values() + for description in INTELLICLIMA_SENSORS + ] + + async_add_entities(entities) + + +class IntelliClimaSensor(IntelliClimaECOEntity, SensorEntity): + """Extends IntelliClimaEntity with Sensor specific logic.""" + + entity_description: IntelliClimaSensorEntityDescription + + def __init__( + self, + coordinator: IntelliClimaCoordinator, + device: IntelliClimaECO, + description: IntelliClimaSensorEntityDescription, + ) -> None: + """Class initializer.""" + super().__init__(coordinator, device) + + self.entity_description = description + + self._attr_unique_id = f"{device.id}_{description.key}" + + @property + def native_value(self) -> int | float | str | None: + """Use this to get the correct value.""" + return self.entity_description.value_fn(self._device_data) diff --git a/homeassistant/components/intelliclima/strings.json b/homeassistant/components/intelliclima/strings.json index 4fdd15a1ca21ed..7c8e1b25053315 100644 --- a/homeassistant/components/intelliclima/strings.json +++ b/homeassistant/components/intelliclima/strings.json @@ -6,7 +6,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "no_devices": "No IntelliClima devices found in your account", + "no_devices": "No supported IntelliClima devices were found in your account", "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { @@ -22,5 +22,18 @@ "description": "Authenticate against IntelliClima cloud" } } + }, + "entity": { + "select": { + "fan_mode": { + "name": "Fan direction mode", + "state": { + "alternate": "Alternating", + "forward": "Forward", + "reverse": "Reverse", + "sensor": "Sensor" + } + } + } } } diff --git a/homeassistant/components/intellifire/__init__.py b/homeassistant/components/intellifire/__init__.py index cc5da82ab92787..8a325152120346 100644 --- a/homeassistant/components/intellifire/__init__.py +++ b/homeassistant/components/intellifire/__init__.py @@ -6,6 +6,7 @@ from intellifire4py import UnifiedFireplace from intellifire4py.cloud_interface import IntelliFireCloudInterface +from intellifire4py.const import IntelliFireApiMode from intellifire4py.model import IntelliFireCommonFireplaceData from homeassistant.const import ( @@ -20,6 +21,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import ( + API_MODE_LOCAL, CONF_AUTH_COOKIE, CONF_CONTROL_MODE, CONF_READ_MODE, @@ -55,8 +57,10 @@ def _construct_common_data( serial=entry.data[CONF_SERIAL], api_key=entry.data[CONF_API_KEY], ip_address=entry.data[CONF_IP_ADDRESS], - read_mode=entry.options[CONF_READ_MODE], - control_mode=entry.options[CONF_CONTROL_MODE], + read_mode=IntelliFireApiMode(entry.options.get(CONF_READ_MODE, API_MODE_LOCAL)), + control_mode=IntelliFireApiMode( + entry.options.get(CONF_CONTROL_MODE, API_MODE_LOCAL) + ), ) @@ -97,12 +101,34 @@ async def async_migrate_entry( hass.config_entries.async_update_entry( config_entry, data=new, - options={CONF_READ_MODE: "local", CONF_CONTROL_MODE: "local"}, + options={ + CONF_READ_MODE: API_MODE_LOCAL, + CONF_CONTROL_MODE: API_MODE_LOCAL, + }, unique_id=new[CONF_SERIAL], version=1, - minor_version=2, + minor_version=3, ) - LOGGER.debug("Pseudo Migration %s successful", config_entry.version) + LOGGER.debug("Migration to 1.3 successful") + + if config_entry.minor_version < 3: + # Migrate old option keys (cloud_read, cloud_control) to new keys + old_options = config_entry.options + new_options = { + CONF_READ_MODE: old_options.get( + "cloud_read", old_options.get(CONF_READ_MODE, API_MODE_LOCAL) + ), + CONF_CONTROL_MODE: old_options.get( + "cloud_control", old_options.get(CONF_CONTROL_MODE, API_MODE_LOCAL) + ), + } + hass.config_entries.async_update_entry( + config_entry, + options=new_options, + version=1, + minor_version=3, + ) + LOGGER.debug("Migration to 1.3 successful (options keys renamed)") return True @@ -139,9 +165,43 @@ async def async_setup_entry(hass: HomeAssistant, entry: IntellifireConfigEntry) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(async_update_options)) + return True +async def async_update_options( + hass: HomeAssistant, entry: IntellifireConfigEntry +) -> None: + """Handle options update.""" + coordinator: IntellifireDataUpdateCoordinator = entry.runtime_data + + new_read_mode = IntelliFireApiMode( + entry.options.get(CONF_READ_MODE, API_MODE_LOCAL) + ) + new_control_mode = IntelliFireApiMode( + entry.options.get(CONF_CONTROL_MODE, API_MODE_LOCAL) + ) + + fireplace = coordinator.fireplace + current_read_mode = fireplace.read_mode + current_control_mode = fireplace.control_mode + + # Only update modes that actually changed + if new_read_mode != current_read_mode: + LOGGER.debug("Updating read mode: %s -> %s", current_read_mode, new_read_mode) + await fireplace.set_read_mode(new_read_mode) + + if new_control_mode != current_control_mode: + LOGGER.debug( + "Updating control mode: %s -> %s", current_control_mode, new_control_mode + ) + await fireplace.set_control_mode(new_control_mode) + + # Refresh data with new mode settings + await coordinator.async_request_refresh() + + async def _async_wait_for_initialization( fireplace: UnifiedFireplace, timeout=STARTUP_TIMEOUT ): diff --git a/homeassistant/components/intellifire/config_flow.py b/homeassistant/components/intellifire/config_flow.py index f6131ede00ac67..e58a5e46559a23 100644 --- a/homeassistant/components/intellifire/config_flow.py +++ b/homeassistant/components/intellifire/config_flow.py @@ -13,7 +13,12 @@ from intellifire4py.model import IntelliFireCommonFireplaceData import voluptuous as vol -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_REAUTH, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.const import ( CONF_API_KEY, CONF_HOST, @@ -21,9 +26,12 @@ CONF_PASSWORD, CONF_USERNAME, ) +from homeassistant.core import callback +from homeassistant.helpers import selector from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import ( + API_MODE_CLOUD, API_MODE_LOCAL, CONF_AUTH_COOKIE, CONF_CONTROL_MODE, @@ -34,6 +42,7 @@ DOMAIN, LOGGER, ) +from .coordinator import IntellifireConfigEntry STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) @@ -70,7 +79,7 @@ class IntelliFireConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for IntelliFire.""" VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 3 def __init__(self) -> None: """Initialize the Config Flow Handler.""" @@ -260,3 +269,85 @@ async def async_step_dhcp( return self.async_abort(reason="not_intellifire_device") return await self.async_step_cloud_api() + + @staticmethod + @callback + def async_get_options_flow(config_entry: IntellifireConfigEntry) -> OptionsFlow: + """Create the options flow.""" + return IntelliFireOptionsFlowHandler() + + +class IntelliFireOptionsFlowHandler(OptionsFlow): + """Options flow for IntelliFire component.""" + + config_entry: IntellifireConfigEntry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + errors: dict[str, str] = {} + + if user_input is not None: + # Validate connectivity for requested modes if runtime data is available + coordinator = self.config_entry.runtime_data + if coordinator is not None: + fireplace = coordinator.fireplace + + # Refresh connectivity status before validating + await fireplace.async_validate_connectivity() + + if ( + user_input[CONF_READ_MODE] == API_MODE_LOCAL + and not fireplace.local_connectivity + ): + errors[CONF_READ_MODE] = "local_unavailable" + if ( + user_input[CONF_READ_MODE] == API_MODE_CLOUD + and not fireplace.cloud_connectivity + ): + errors[CONF_READ_MODE] = "cloud_unavailable" + if ( + user_input[CONF_CONTROL_MODE] == API_MODE_LOCAL + and not fireplace.local_connectivity + ): + errors[CONF_CONTROL_MODE] = "local_unavailable" + if ( + user_input[CONF_CONTROL_MODE] == API_MODE_CLOUD + and not fireplace.cloud_connectivity + ): + errors[CONF_CONTROL_MODE] = "cloud_unavailable" + + if not errors: + return self.async_create_entry(title="", data=user_input) + + existing_read = self.config_entry.options.get(CONF_READ_MODE, API_MODE_LOCAL) + existing_control = self.config_entry.options.get( + CONF_CONTROL_MODE, API_MODE_LOCAL + ) + + cloud_local_options = selector.SelectSelectorConfig( + options=[API_MODE_LOCAL, API_MODE_CLOUD], + translation_key="api_mode", + ) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required( + CONF_READ_MODE, + default=user_input.get(CONF_READ_MODE, existing_read) + if user_input + else existing_read, + ): selector.SelectSelector(cloud_local_options), + vol.Required( + CONF_CONTROL_MODE, + default=user_input.get(CONF_CONTROL_MODE, existing_control) + if user_input + else existing_control, + ): selector.SelectSelector(cloud_local_options), + } + ), + errors=errors, + ) diff --git a/homeassistant/components/intellifire/const.py b/homeassistant/components/intellifire/const.py index f194eeaf4e2d7d..051bb01f9d4d2b 100644 --- a/homeassistant/components/intellifire/const.py +++ b/homeassistant/components/intellifire/const.py @@ -13,8 +13,8 @@ CONF_AUTH_COOKIE = "auth_cookie" # part of the cloud cookie CONF_SERIAL = "serial" -CONF_READ_MODE = "cloud_read" -CONF_CONTROL_MODE = "cloud_control" +CONF_READ_MODE = "read_mode" +CONF_CONTROL_MODE = "control_mode" API_MODE_LOCAL = "local" diff --git a/homeassistant/components/intellifire/light.py b/homeassistant/components/intellifire/light.py index c73614bfade4cf..a40441d640da3e 100644 --- a/homeassistant/components/intellifire/light.py +++ b/homeassistant/components/intellifire/light.py @@ -61,7 +61,7 @@ def brightness(self) -> int: return 85 * self.entity_description.value_fn(self.coordinator.read_api.data) @property - def is_on(self): + def is_on(self) -> bool: """Return true if light is on.""" return self.entity_description.value_fn(self.coordinator.read_api.data) >= 1 diff --git a/homeassistant/components/intellifire/manifest.json b/homeassistant/components/intellifire/manifest.json index ae9067ca01ef7a..4feef90a7f7289 100644 --- a/homeassistant/components/intellifire/manifest.json +++ b/homeassistant/components/intellifire/manifest.json @@ -12,5 +12,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["intellifire4py"], - "requirements": ["intellifire4py==4.3.1"] + "requirements": ["intellifire4py==4.4.0"] } diff --git a/homeassistant/components/intellifire/sensor.py b/homeassistant/components/intellifire/sensor.py index 82abc0d3797354..6b96f138eefd2f 100644 --- a/homeassistant/components/intellifire/sensor.py +++ b/homeassistant/components/intellifire/sensor.py @@ -17,6 +17,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utcnow +from .const import API_MODE_CLOUD, API_MODE_LOCAL from .coordinator import IntellifireConfigEntry, IntellifireDataUpdateCoordinator from .entity import IntellifireEntity @@ -66,6 +67,22 @@ def _uptime_to_timestamp( INTELLIFIRE_SENSORS: tuple[IntellifireSensorEntityDescription, ...] = ( + IntellifireSensorEntityDescription( + key="read_mode", + translation_key="read_mode", + device_class=SensorDeviceClass.ENUM, + options=[API_MODE_LOCAL, API_MODE_CLOUD], + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda coordinator: coordinator.fireplace.read_mode.value, + ), + IntellifireSensorEntityDescription( + key="control_mode", + translation_key="control_mode", + device_class=SensorDeviceClass.ENUM, + options=[API_MODE_LOCAL, API_MODE_CLOUD], + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda coordinator: coordinator.fireplace.control_mode.value, + ), IntellifireSensorEntityDescription( key="flame_height", translation_key="flame_height", @@ -97,7 +114,6 @@ def _uptime_to_timestamp( IntellifireSensorEntityDescription( key="timer_end_timestamp", translation_key="timer_end_timestamp", - state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TIMESTAMP, value_fn=_time_remaining_to_timestamp, ), diff --git a/homeassistant/components/intellifire/strings.json b/homeassistant/components/intellifire/strings.json index 7c6c349b564de2..3faca975f0122c 100644 --- a/homeassistant/components/intellifire/strings.json +++ b/homeassistant/components/intellifire/strings.json @@ -22,7 +22,13 @@ "description": "Authenticate against IntelliFire cloud" }, "pick_cloud_device": { - "description": "Select fireplace by serial number:", + "data": { + "serial": "Fireplace serial number" + }, + "data_description": { + "serial": "Serial number of the fireplace to configure" + }, + "description": "Select fireplace by serial number.", "title": "Configure fireplace" } } @@ -100,6 +106,13 @@ "connection_quality": { "name": "Connection quality" }, + "control_mode": { + "name": "Control mode", + "state": { + "cloud": "Cloud", + "local": "Local" + } + }, "downtime": { "name": "Downtime" }, @@ -115,6 +128,13 @@ "ipv4_address": { "name": "IP address" }, + "read_mode": { + "name": "Read mode", + "state": { + "cloud": "Cloud", + "local": "Local" + } + }, "target_temp": { "name": "Target temperature" }, @@ -133,5 +153,33 @@ "name": "Pilot light" } } + }, + "options": { + "error": { + "cloud_unavailable": "Cloud connectivity is not available", + "local_unavailable": "Local connectivity is not available" + }, + "step": { + "init": { + "data": { + "control_mode": "Send commands to", + "read_mode": "Read data from" + }, + "data_description": { + "control_mode": "Whether to send fireplace commands via the `Local` or `Cloud` API", + "read_mode": "Whether to read fireplace state via the `Local` or `Cloud` API" + }, + "description": "Some users find that their fireplace hardware prioritizes `Cloud` communication and may experience timeouts with `Local` control. If you encounter connectivity issues, try switching to `Cloud` for the affected endpoint.", + "title": "Endpoint selection" + } + } + }, + "selector": { + "api_mode": { + "options": { + "cloud": "Cloud", + "local": "Local" + } + } } } diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 56b8d7842ba54d..690fccbf29fd4b 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -627,13 +627,17 @@ class IntentHandleView(http.HomeAssistantView): { vol.Required("name"): cv.string, vol.Optional("data"): vol.Schema({cv.string: object}), + vol.Optional("language"): cv.string, + vol.Optional("assistant"): vol.Any(cv.string, None), + vol.Optional("device_id"): vol.Any(cv.string, None), + vol.Optional("satellite_id"): vol.Any(cv.string, None), } ) ) async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle intent with name/data.""" hass = request.app[http.KEY_HASS] - language = hass.config.language + language = data.get("language", hass.config.language) try: intent_name = data["name"] @@ -641,14 +645,21 @@ async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response key: {"value": value} for key, value in data.get("data", {}).items() } intent_result = await intent.async_handle( - hass, DOMAIN, intent_name, slots, "", self.context(request) + hass, + DOMAIN, + intent_name, + slots, + "", + self.context(request), + language=language, + assistant=data.get("assistant"), + device_id=data.get("device_id"), + satellite_id=data.get("satellite_id"), ) except (intent.IntentHandleError, intent.MatchFailedError) as err: intent_result = intent.IntentResponse(language=language) - intent_result.async_set_speech(str(err)) - - if intent_result is None: - intent_result = intent.IntentResponse(language=language) # type: ignore[unreachable] - intent_result.async_set_speech("Sorry, I couldn't handle that") + intent_result.async_set_error( + intent.IntentResponseErrorCode.FAILED_TO_HANDLE, str(err) + ) return self.json(intent_result) diff --git a/homeassistant/components/intesishome/climate.py b/homeassistant/components/intesishome/climate.py index 3465a7e5c07706..c0ad603ba17ea4 100644 --- a/homeassistant/components/intesishome/climate.py +++ b/homeassistant/components/intesishome/climate.py @@ -218,16 +218,16 @@ async def async_added_to_hass(self) -> None: raise PlatformNotReady from ex @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the device specific state attributes.""" attrs = {} - if self._outdoor_temp: + if self._outdoor_temp is not None: attrs["outdoor_temp"] = self._outdoor_temp - if self._power_consumption_heat: + if self._power_consumption_heat is not None: attrs["power_consumption_heat_kw"] = round( self._power_consumption_heat / 1000, 1 ) - if self._power_consumption_cool: + if self._power_consumption_cool is not None: attrs["power_consumption_cool_kw"] = round( self._power_consumption_cool / 1000, 1 ) @@ -244,7 +244,7 @@ async def async_set_temperature(self, **kwargs: Any) -> None: if hvac_mode := kwargs.get(ATTR_HVAC_MODE): await self.async_set_hvac_mode(hvac_mode) - if temperature := kwargs.get(ATTR_TEMPERATURE): + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is not None: _LOGGER.debug("Setting %s to %s degrees", self._device_type, temperature) await self._controller.set_temperature(self._device_id, temperature) self._attr_target_temperature = temperature @@ -271,7 +271,7 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: await self._controller.set_mode(self._device_id, MAP_HVAC_MODE_TO_IH[hvac_mode]) # Send the temperature again in case changing modes has changed it - if self._attr_target_temperature: + if self._attr_target_temperature is not None: await self._controller.set_temperature( self._device_id, self._attr_target_temperature ) diff --git a/homeassistant/components/iometer/sensor.py b/homeassistant/components/iometer/sensor.py index 01dc90addfaa04..b83b4a23dd6ae5 100644 --- a/homeassistant/components/iometer/sensor.py +++ b/homeassistant/components/iometer/sensor.py @@ -86,6 +86,22 @@ class IOmeterEntityDescription(SensorEntityDescription): options=["entered", "pending", "missing", "unknown"], value_fn=lambda data: data.status.device.core.pin_status or STATE_UNKNOWN, ), + IOmeterEntityDescription( + key="consumption_tariff_t1", + translation_key="consumption_tariff_t1", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + value_fn=lambda data: data.reading.get_consumption_tariff_T1(), + ), + IOmeterEntityDescription( + key="consumption_tariff_t2", + translation_key="consumption_tariff_t2", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + value_fn=lambda data: data.reading.get_consumption_tariff_T2(), + ), IOmeterEntityDescription( key="total_consumption", translation_key="total_consumption", diff --git a/homeassistant/components/iometer/strings.json b/homeassistant/components/iometer/strings.json index 3c77222bccdb3f..a77ff80c6433aa 100644 --- a/homeassistant/components/iometer/strings.json +++ b/homeassistant/components/iometer/strings.json @@ -39,6 +39,12 @@ "battery_level": { "name": "Battery level" }, + "consumption_tariff_t1": { + "name": "Consumption Tariff T1" + }, + "consumption_tariff_t2": { + "name": "Consumption Tariff T2" + }, "core_bridge_rssi": { "name": "Signal strength Core/Bridge" }, diff --git a/homeassistant/components/iperf3/sensor.py b/homeassistant/components/iperf3/sensor.py index 9ba3b55ed4f3d1..b30e019798c83a 100644 --- a/homeassistant/components/iperf3/sensor.py +++ b/homeassistant/components/iperf3/sensor.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant, callback @@ -50,7 +52,7 @@ def __init__(self, iperf3_data, description: SensorEntityDescription) -> None: self._attr_name = f"{description.name} {iperf3_data.host}" @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return { ATTR_PROTOCOL: self._iperf3_data.protocol, diff --git a/homeassistant/components/ipma/weather.py b/homeassistant/components/ipma/weather.py index 74344da8affb33..02689a4b791eaa 100644 --- a/homeassistant/components/ipma/weather.py +++ b/homeassistant/components/ipma/weather.py @@ -129,7 +129,7 @@ def _condition_conversion(self, identifier, forecast_dt): return CONDITION_MAP.get(identifier) @property - def condition(self): + def condition(self) -> str | None: """Return the current condition which is only available on the hourly forecast data.""" forecast = self._hourly_forecast @@ -139,7 +139,7 @@ def condition(self): return self._condition_conversion(forecast[0].weather_type.id, None) @property - def native_temperature(self): + def native_temperature(self) -> float | None: """Return the current temperature.""" if not self._observation: return None @@ -147,7 +147,7 @@ def native_temperature(self): return self._observation.temperature @property - def native_pressure(self): + def native_pressure(self) -> float | None: """Return the current pressure.""" if not self._observation: return None @@ -155,7 +155,7 @@ def native_pressure(self): return self._observation.pressure @property - def humidity(self): + def humidity(self) -> float | None: """Return the name of the sensor.""" if not self._observation: return None @@ -163,7 +163,7 @@ def humidity(self): return self._observation.humidity @property - def native_wind_speed(self): + def native_wind_speed(self) -> float | None: """Return the current windspeed.""" if not self._observation: return None @@ -171,7 +171,7 @@ def native_wind_speed(self): return self._observation.wind_intensity_km @property - def wind_bearing(self): + def wind_bearing(self) -> float | None: """Return the current wind bearing (degrees).""" if not self._observation: return None diff --git a/homeassistant/components/iron_os/number.py b/homeassistant/components/iron_os/number.py index 71d340148ffd05..e9056bc9abca8f 100644 --- a/homeassistant/components/iron_os/number.py +++ b/homeassistant/components/iron_os/number.py @@ -358,7 +358,7 @@ def multiply(value: float | None, multiplier: float) -> float | None: native_max_value=MAX_TEMP, native_min_value_f=MIN_TEMP_F, native_max_value_f=MAX_TEMP_F, - native_step=5, + native_step=1, ) diff --git a/homeassistant/components/iron_os/update.py b/homeassistant/components/iron_os/update.py index fba60a8ddafcf5..ca7e7581067449 100644 --- a/homeassistant/components/iron_os/update.py +++ b/homeassistant/components/iron_os/update.py @@ -9,6 +9,7 @@ UpdateEntityDescription, UpdateEntityFeature, ) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -22,6 +23,7 @@ UPDATE_DESCRIPTION = UpdateEntityDescription( key="firmware", device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.DIAGNOSTIC, ) diff --git a/homeassistant/components/iss/__init__.py b/homeassistant/components/iss/__init__.py index dbbcc8b6c518f8..d8ffa9c215d9eb 100644 --- a/homeassistant/components/iss/__init__.py +++ b/homeassistant/components/iss/__init__.py @@ -2,66 +2,21 @@ from __future__ import annotations -from dataclasses import dataclass -from datetime import timedelta -import logging - -import pyiss -import requests -from requests.exceptions import HTTPError - -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed - -from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) +from .coordinator import IssConfigEntry, IssDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] -@dataclass -class IssData: - """Dataclass representation of data returned from pyiss.""" - - number_of_people_in_space: int - current_location: dict[str, str] - - -def update(iss: pyiss.ISS) -> IssData: - """Retrieve data from the pyiss API.""" - return IssData( - number_of_people_in_space=iss.number_of_people_in_space(), - current_location=iss.current_location(), - ) - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: IssConfigEntry) -> bool: """Set up this integration using UI.""" - hass.data.setdefault(DOMAIN, {}) - - iss = pyiss.ISS() - - async def async_update() -> IssData: - try: - return await hass.async_add_executor_job(update, iss) - except (HTTPError, requests.exceptions.ConnectionError) as ex: - raise UpdateFailed("Unable to retrieve data") from ex - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name=DOMAIN, - update_method=async_update, - update_interval=timedelta(seconds=60), - ) + coordinator = IssDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN] = coordinator + entry.runtime_data = coordinator entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -70,13 +25,11 @@ async def async_update() -> IssData: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: IssConfigEntry) -> 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) -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: IssConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/iss/config_flow.py b/homeassistant/components/iss/config_flow.py index eaf01a6d0946c1..5aa49c3d45a8f9 100644 --- a/homeassistant/components/iss/config_flow.py +++ b/homeassistant/components/iss/config_flow.py @@ -4,16 +4,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_SHOW_ON_MAP from homeassistant.core import callback from .const import DEFAULT_NAME, DOMAIN +from .coordinator import IssConfigEntry class ISSConfigFlow(ConfigFlow, domain=DOMAIN): @@ -24,7 +20,7 @@ class ISSConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: IssConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() diff --git a/homeassistant/components/iss/const.py b/homeassistant/components/iss/const.py index c3bdcf6fa327e6..264e24352b0ae5 100644 --- a/homeassistant/components/iss/const.py +++ b/homeassistant/components/iss/const.py @@ -3,3 +3,5 @@ DOMAIN = "iss" DEFAULT_NAME = "ISS" + +MAX_CONSECUTIVE_FAILURES = 5 diff --git a/homeassistant/components/iss/coordinator.py b/homeassistant/components/iss/coordinator.py new file mode 100644 index 00000000000000..88a9c8ebbdbc95 --- /dev/null +++ b/homeassistant/components/iss/coordinator.py @@ -0,0 +1,76 @@ +"""DataUpdateCoordinator for the ISS integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging + +import pyiss +import requests +from requests.exceptions import HTTPError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, MAX_CONSECUTIVE_FAILURES + +type IssConfigEntry = ConfigEntry[IssDataUpdateCoordinator] + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class IssData: + """Dataclass representation of data returned from pyiss.""" + + number_of_people_in_space: int + current_location: dict[str, str] + + +class IssDataUpdateCoordinator(DataUpdateCoordinator[IssData]): + """ISS coordinator that tolerates transient API failures.""" + + config_entry: IssConfigEntry + + def __init__(self, hass: HomeAssistant, entry: IssConfigEntry) -> None: + """Initialize the ISS coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=timedelta(seconds=60), + ) + self._consecutive_failures = 0 + self.iss = pyiss.ISS() + + def _fetch_iss_data(self) -> IssData: + """Fetch data from ISS API (blocking).""" + return IssData( + number_of_people_in_space=self.iss.number_of_people_in_space(), + current_location=self.iss.current_location(), + ) + + async def _async_update_data(self) -> IssData: + """Fetch data from the ISS API, tolerating transient failures.""" + try: + data = await self.hass.async_add_executor_job(self._fetch_iss_data) + except (HTTPError, requests.exceptions.ConnectionError) as err: + self._consecutive_failures += 1 + if self.data is None: + raise UpdateFailed("Unable to retrieve data") from err + if self._consecutive_failures >= MAX_CONSECUTIVE_FAILURES: + raise UpdateFailed( + f"Unable to retrieve data after {self._consecutive_failures} consecutive update failures" + ) from err + _LOGGER.debug( + "Transient API error (%s/%s), using cached data: %s", + self._consecutive_failures, + MAX_CONSECUTIVE_FAILURES, + err, + ) + return self.data + self._consecutive_failures = 0 + return data diff --git a/homeassistant/components/iss/sensor.py b/homeassistant/components/iss/sensor.py index b6e98e07f8a8bf..b7fa190c3bde2a 100644 --- a/homeassistant/components/iss/sensor.py +++ b/homeassistant/components/iss/sensor.py @@ -6,36 +6,32 @@ from typing import Any from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_SHOW_ON_MAP 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, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import IssData from .const import DEFAULT_NAME, DOMAIN +from .coordinator import IssConfigEntry, IssDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IssConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" - coordinator: DataUpdateCoordinator[IssData] = hass.data[DOMAIN] + coordinator = entry.runtime_data show_on_map = entry.options.get(CONF_SHOW_ON_MAP, False) async_add_entities([IssSensor(coordinator, entry, show_on_map)]) -class IssSensor(CoordinatorEntity[DataUpdateCoordinator[IssData]], SensorEntity): +class IssSensor(CoordinatorEntity[IssDataUpdateCoordinator], SensorEntity): """Implementation of the ISS sensor.""" _attr_has_entity_name = True @@ -43,8 +39,8 @@ class IssSensor(CoordinatorEntity[DataUpdateCoordinator[IssData]], SensorEntity) def __init__( self, - coordinator: DataUpdateCoordinator[IssData], - entry: ConfigEntry, + coordinator: IssDataUpdateCoordinator, + entry: IssConfigEntry, show: bool, ) -> None: """Initialize the sensor.""" diff --git a/homeassistant/components/itach/remote.py b/homeassistant/components/itach/remote.py index 235d290cccbcea..9b53525bd9a1b6 100644 --- a/homeassistant/components/itach/remote.py +++ b/homeassistant/components/itach/remote.py @@ -112,30 +112,20 @@ class ITachIP2IRRemote(remote.RemoteEntity): def __init__(self, itachip2ir, name, ir_count): """Initialize device.""" self.itachip2ir = itachip2ir - self._power = False - self._name = name or DEVICE_DEFAULT_NAME + self._attr_is_on = False + self._attr_name = name or DEVICE_DEFAULT_NAME self._ir_count = ir_count or DEFAULT_IR_COUNT - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def is_on(self): - """Return true if device is on.""" - return self._power - def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - self._power = True - self.itachip2ir.send(self._name, "ON", self._ir_count) + self._attr_is_on = True + self.itachip2ir.send(self.name, "ON", self._ir_count) self.schedule_update_ha_state() def turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - self._power = False - self.itachip2ir.send(self._name, "OFF", self._ir_count) + self._attr_is_on = False + self.itachip2ir.send(self.name, "OFF", self._ir_count) self.schedule_update_ha_state() def send_command(self, command: Iterable[str], **kwargs: Any) -> None: @@ -143,7 +133,7 @@ def send_command(self, command: Iterable[str], **kwargs: Any) -> None: num_repeats = kwargs.get(ATTR_NUM_REPEATS, DEFAULT_NUM_REPEATS) for single_command in command: self.itachip2ir.send( - self._name, single_command, self._ir_count * num_repeats + self.name, single_command, self._ir_count * num_repeats ) def update(self) -> None: diff --git a/homeassistant/components/itunes/media_player.py b/homeassistant/components/itunes/media_player.py index 92e3aefe9750e6..373f1003b0a816 100644 --- a/homeassistant/components/itunes/media_player.py +++ b/homeassistant/components/itunes/media_player.py @@ -451,7 +451,7 @@ def name(self): return self.device_name @property - def icon(self): + def icon(self) -> str: """Return the icon to use in the frontend, if any.""" if self.selected is True: return "mdi:volume-high" diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index 9a7a8b1dcf3d9a..f0fd93834e10b8 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -603,7 +603,7 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: self.async_write_ha_state() @property - def is_on(self): + def is_on(self) -> bool: """Return true if on.""" return self._zone.mode != Zone.Mode.CLOSE diff --git a/homeassistant/components/jellyfin/__init__.py b/homeassistant/components/jellyfin/__init__.py index d22594070ff7c9..796d3b298eeafa 100644 --- a/homeassistant/components/jellyfin/__init__.py +++ b/homeassistant/components/jellyfin/__init__.py @@ -4,11 +4,21 @@ from homeassistant.core import HomeAssistant 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.typing import ConfigType from .client_wrapper import CannotConnect, InvalidAuth, create_client, validate_input from .const import CONF_CLIENT_DEVICE_ID, DEFAULT_NAME, DOMAIN, PLATFORMS from .coordinator import JellyfinConfigEntry, JellyfinDataUpdateCoordinator +from .services import async_setup_services + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Jellyfin component.""" + await async_setup_services(hass) + return True async def async_setup_entry(hass: HomeAssistant, entry: JellyfinConfigEntry) -> bool: diff --git a/homeassistant/components/jellyfin/browse_media.py b/homeassistant/components/jellyfin/browse_media.py index 9dc84971a211e2..1441559105ea68 100644 --- a/homeassistant/components/jellyfin/browse_media.py +++ b/homeassistant/components/jellyfin/browse_media.py @@ -38,6 +38,8 @@ MediaType.EPISODE, MediaType.MOVIE, MediaType.MUSIC, + MediaType.SEASON, + MediaType.TVSHOW, ] @@ -98,8 +100,8 @@ async def build_item_response( media_content_id: str, ) -> BrowseMedia: """Create response payload for the provided media query.""" - title, media, thumbnail = await get_media_info( - hass, client, user_id, media_content_type, media_content_id + title, media, thumbnail, media_type = await get_media_info( + hass, client, user_id, media_content_id ) if title is None or media is None: @@ -111,12 +113,12 @@ async def build_item_response( response = BrowseMedia( media_class=CONTAINER_TYPES_SPECIFIC_MEDIA_CLASS.get( - str(media_content_type), MediaClass.DIRECTORY + str(media_type), MediaClass.DIRECTORY ), media_content_id=media_content_id, - media_content_type=str(media_content_type), + media_content_type=str(media_type), title=title, - can_play=bool(media_content_type in PLAYABLE_MEDIA_TYPES and media_content_id), + can_play=bool(media_type in PLAYABLE_MEDIA_TYPES and media_content_id), can_expand=True, children=children, thumbnail=thumbnail, @@ -207,18 +209,18 @@ async def get_media_info( hass: HomeAssistant, client: JellyfinClient, user_id: str, - media_content_type: str | None, media_content_id: str, -) -> tuple[str | None, list[dict[str, Any]] | None, str | None]: +) -> tuple[str | None, list[dict[str, Any]] | None, str | None, str | None]: """Fetch media info.""" thumbnail: str | None = None title: str | None = None media: list[dict[str, Any]] | None = None + media_type: str | None = None item = await hass.async_add_executor_job(fetch_item, client, media_content_id) if item is None: - return None, None, None + return None, None, None, None title = item["Name"] thumbnail = get_artwork_url(client, item) @@ -231,4 +233,6 @@ async def get_media_info( if not media or len(media) == 0: media = None - return title, media, thumbnail + media_type = CONTENT_TYPE_MAP.get(item["Type"], MEDIA_TYPE_NONE) + + return title, media, thumbnail, media_type diff --git a/homeassistant/components/jellyfin/const.py b/homeassistant/components/jellyfin/const.py index cdddaa46ad102f..2e2dc37b601ae3 100644 --- a/homeassistant/components/jellyfin/const.py +++ b/homeassistant/components/jellyfin/const.py @@ -74,9 +74,10 @@ "MusicAlbum": MediaClass.ALBUM, "MusicArtist": MediaClass.ARTIST, "Audio": MediaClass.MUSIC, - "Series": MediaClass.DIRECTORY, + "Series": MediaClass.TV_SHOW, "Movie": MediaClass.MOVIE, "CollectionFolder": MediaClass.DIRECTORY, + "AggregateFolder": MediaClass.DIRECTORY, "Folder": MediaClass.DIRECTORY, "BoxSet": MediaClass.DIRECTORY, "Episode": MediaClass.EPISODE, diff --git a/homeassistant/components/jellyfin/icons.json b/homeassistant/components/jellyfin/icons.json index 6dcfa4b2706b61..6b41eda1987888 100644 --- a/homeassistant/components/jellyfin/icons.json +++ b/homeassistant/components/jellyfin/icons.json @@ -5,5 +5,10 @@ "default": "mdi:television-play" } } + }, + "services": { + "play_media_shuffle": { + "service": "mdi:shuffle-variant" + } } } diff --git a/homeassistant/components/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py index eb463d8bed00a5..9ae4af39a98ce6 100644 --- a/homeassistant/components/jellyfin/media_player.py +++ b/homeassistant/components/jellyfin/media_player.py @@ -6,7 +6,9 @@ from typing import Any from homeassistant.components.media_player import ( + ATTR_MEDIA_ENQUEUE, BrowseMedia, + MediaPlayerEnqueue, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, @@ -203,6 +205,7 @@ def supported_features(self) -> MediaPlayerEntityFeature: | MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.SEEK | MediaPlayerEntityFeature.SEARCH_MEDIA + | MediaPlayerEntityFeature.MEDIA_ENQUEUE ) if "Mute" in commands and "Unmute" in commands: @@ -245,8 +248,20 @@ def play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play a piece of media.""" + command = "PlayNow" + enqueue = kwargs.get(ATTR_MEDIA_ENQUEUE) + if enqueue == MediaPlayerEnqueue.NEXT: + command = "PlayNext" + elif enqueue == MediaPlayerEnqueue.ADD: + command = "PlayLast" self.coordinator.api_client.jellyfin.remote_play_media( - self.session_id, [media_id] + self.session_id, [media_id], command + ) + + def play_media_shuffle(self, media_content_id: str) -> None: + """Play a piece of media on shuffle.""" + self.coordinator.api_client.jellyfin.remote_play_media( + self.session_id, [media_content_id], "PlayShuffle" ) def set_volume_level(self, volume: float) -> None: diff --git a/homeassistant/components/jellyfin/services.py b/homeassistant/components/jellyfin/services.py new file mode 100644 index 00000000000000..d829d4a1ff0eef --- /dev/null +++ b/homeassistant/components/jellyfin/services.py @@ -0,0 +1,55 @@ +"""Services for the Jellyfin integration.""" + +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.components.media_player import ( + ATTR_MEDIA, + ATTR_MEDIA_CONTENT_ID, + DOMAIN as MP_DOMAIN, + MediaPlayerEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv, service + +from .const import DOMAIN + +JELLYFIN_PLAY_MEDIA_SHUFFLE_SCHEMA = { + vol.Required(ATTR_MEDIA_CONTENT_ID): cv.string, +} + + +def _promote_media_fields(data: dict[str, Any]) -> dict[str, Any]: + """If 'media' key exists, promote its fields to the top level.""" + if ATTR_MEDIA in data and isinstance(data[ATTR_MEDIA], dict): + if ATTR_MEDIA_CONTENT_ID in data: + raise vol.Invalid( + f"Play media cannot contain both '{ATTR_MEDIA}' and '{ATTR_MEDIA_CONTENT_ID}'" + ) + media_data = data[ATTR_MEDIA] + + if ATTR_MEDIA_CONTENT_ID in media_data: + data[ATTR_MEDIA_CONTENT_ID] = media_data[ATTR_MEDIA_CONTENT_ID] + + del data[ATTR_MEDIA] + return data + + +async def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for the Jellyfin component.""" + + service.async_register_platform_entity_service( + hass, + DOMAIN, + "play_media_shuffle", + entity_domain=MP_DOMAIN, + schema=vol.All( + _promote_media_fields, + cv.make_entity_service_schema(JELLYFIN_PLAY_MEDIA_SHUFFLE_SCHEMA), + ), + func="play_media_shuffle", + required_features=MediaPlayerEntityFeature.PLAY_MEDIA, + ) diff --git a/homeassistant/components/jellyfin/services.yaml b/homeassistant/components/jellyfin/services.yaml new file mode 100644 index 00000000000000..db42784609b8a4 --- /dev/null +++ b/homeassistant/components/jellyfin/services.yaml @@ -0,0 +1,11 @@ +play_media_shuffle: + target: + entity: + integration: jellyfin + domain: media_player + fields: + media: + required: true + selector: + media: + example: '{"media_content_id": "a656b907eb3a73532e40e44b968d0225"}' diff --git a/homeassistant/components/jellyfin/strings.json b/homeassistant/components/jellyfin/strings.json index 258b0f362168ce..fa3881f10520a6 100644 --- a/homeassistant/components/jellyfin/strings.json +++ b/homeassistant/components/jellyfin/strings.json @@ -42,5 +42,17 @@ } } } + }, + "services": { + "play_media_shuffle": { + "description": "Starts playing specified media shuffled. Overwrites current play queue.", + "fields": { + "media": { + "description": "The media selected to play.", + "name": "Media" + } + }, + "name": "Play media shuffled" + } } } diff --git a/homeassistant/components/jvc_projector/coordinator.py b/homeassistant/components/jvc_projector/coordinator.py index ccd125b98ed506..cbde80b65bc902 100644 --- a/homeassistant/components/jvc_projector/coordinator.py +++ b/homeassistant/components/jvc_projector/coordinator.py @@ -151,7 +151,9 @@ async def _update_command_state( return value - def get_options_map(self, command: str) -> dict[str, str]: + def get_options_map( + self, command: str, *, snake_case: bool = False + ) -> dict[str, str]: """Get the available options for a command.""" capabilities = self.capabilities.get(command, {}) @@ -162,7 +164,10 @@ def get_options_map(self, command: str) -> dict[str, str]: values = list(capabilities.get("parameter", {}).get("read", {}).values()) - return {v: v.translate(TRANSLATIONS) for v in values} + options = {v: v.translate(TRANSLATIONS) for v in values} + if snake_case: + return {k: v.replace("-", "_") for k, v in options.items()} + return options def supports(self, command: type[Command]) -> bool: """Check if the device supports a command.""" diff --git a/homeassistant/components/jvc_projector/icons.json b/homeassistant/components/jvc_projector/icons.json index 9280ea6f1e488d..c867f9970e212e 100644 --- a/homeassistant/components/jvc_projector/icons.json +++ b/homeassistant/components/jvc_projector/icons.json @@ -18,6 +18,9 @@ "dynamic_control": { "default": "mdi:lightbulb-on-outline" }, + "hdr_processing": { + "default": "mdi:image-filter-hdr-outline" + }, "input": { "default": "mdi:hdmi-port" }, @@ -26,6 +29,9 @@ }, "light_power": { "default": "mdi:lightbulb-on-outline" + }, + "picture_mode": { + "default": "mdi:movie-roll" } }, "sensor": { diff --git a/homeassistant/components/jvc_projector/manifest.json b/homeassistant/components/jvc_projector/manifest.json index 38c936d241885d..c2b1243a993c5d 100644 --- a/homeassistant/components/jvc_projector/manifest.json +++ b/homeassistant/components/jvc_projector/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["jvcprojector"], - "requirements": ["pyjvcprojector==2.0.1"] + "requirements": ["pyjvcprojector==2.0.3"] } diff --git a/homeassistant/components/jvc_projector/select.py b/homeassistant/components/jvc_projector/select.py index 717cd06e4b5c4f..4d2d48dd1c6eeb 100644 --- a/homeassistant/components/jvc_projector/select.py +++ b/homeassistant/components/jvc_projector/select.py @@ -20,6 +20,7 @@ class JvcProjectorSelectDescription(SelectEntityDescription): """Describes JVC Projector select entities.""" command: type[Command] + snake_case_states: bool = False SELECTS: Final[tuple[JvcProjectorSelectDescription, ...]] = ( @@ -49,6 +50,18 @@ class JvcProjectorSelectDescription(SelectEntityDescription): command=cmd.Anamorphic, entity_registry_enabled_default=False, ), + JvcProjectorSelectDescription( + key="hdr_processing", + command=cmd.HdrProcessing, + entity_registry_enabled_default=False, + snake_case_states=True, + ), + JvcProjectorSelectDescription( + key="picture_mode", + command=cmd.PictureMode, + entity_registry_enabled_default=False, + snake_case_states=True, + ), ) @@ -84,7 +97,8 @@ def __init__( self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" self._options_map: dict[str, str] = coordinator.get_options_map( - self.command.name + self.command.name, + snake_case=description.snake_case_states, ) @property diff --git a/homeassistant/components/jvc_projector/sensor.py b/homeassistant/components/jvc_projector/sensor.py index 626343de01f0e5..8267e62f2bfee3 100644 --- a/homeassistant/components/jvc_projector/sensor.py +++ b/homeassistant/components/jvc_projector/sensor.py @@ -7,16 +7,19 @@ from jvcprojector import Command, command as cmd from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, ) from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import JVCConfigEntry, JvcProjectorDataUpdateCoordinator from .entity import JvcProjectorEntity +from .util import deprecate_entity @dataclass(frozen=True, kw_only=True) @@ -84,12 +87,29 @@ async def async_setup_entry( ) -> None: """Set up the JVC Projector platform from a config entry.""" coordinator = entry.runtime_data - - async_add_entities( - JvcProjectorSensorEntity(coordinator, description) - for description in SENSORS - if coordinator.supports(description.command) - ) + entity_registry = er.async_get(hass) + + entities: list[JvcProjectorSensorEntity] = [] + for description in SENSORS: + if not coordinator.supports(description.command): + continue + if description.key in ( + "hdr_processing", + "picture_mode", + ) and not deprecate_entity( + hass, + entity_registry, + SENSOR_DOMAIN, + f"{coordinator.unique_id}_{description.key}", + f"deprecated_sensor_{entry.entry_id}_{description.key}", + "deprecated_sensor", + f"{coordinator.unique_id}_{description.key}", + f"select.jvc_projector_{description.key}", + ): + continue + entities.append(JvcProjectorSensorEntity(coordinator, description)) + + async_add_entities(entities) class JvcProjectorSensorEntity(JvcProjectorEntity, SensorEntity): diff --git a/homeassistant/components/jvc_projector/strings.json b/homeassistant/components/jvc_projector/strings.json index dee8a8f661ad88..d06b530d4d8284 100644 --- a/homeassistant/components/jvc_projector/strings.json +++ b/homeassistant/components/jvc_projector/strings.json @@ -71,6 +71,15 @@ "off": "[%key:common::state::off%]" } }, + "hdr_processing": { + "name": "HDR Processing", + "state": { + "frame_by_frame": "Frame-by-Frame", + "hdr10p": "HDR10+", + "scene_by_scene": "Scene-by-Scene", + "static": "Static" + } + }, "input": { "name": "Input", "state": { @@ -101,6 +110,23 @@ "mid": "[%key:common::state::medium%]", "normal": "[%key:common::state::normal%]" } + }, + "picture_mode": { + "name": "Picture Mode", + "state": { + "frame_adapt_hdr": "Frame Adapt HDR", + "frame_adapt_hdr2": "Frame Adapt HDR2", + "frame_adapt_hdr3": "Frame Adapt HDR3", + "hdr1": "HDR1", + "hdr10": "HDR10", + "hdr10_ll": "HDR10 LL", + "hdr2": "HDR2", + "last_setting": "Last setting", + "pana_pq": "Pana PQ", + "user_4": "User 4", + "user_5": "User 5", + "user_6": "User 6" + } } }, "sensor": { @@ -156,7 +182,7 @@ "hdr10": "HDR10", "hdr10-ll": "HDR10 LL", "hdr2": "HDR2", - "last-setting": "Last Setting", + "last-setting": "Last setting", "pana-pq": "Pana PQ", "user-4": "User 4", "user-5": "User 5", @@ -182,5 +208,15 @@ "name": "Low latency mode" } } + }, + "issues": { + "deprecated_sensor": { + "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with `{replacement_entity_id}`.\n\nUpdate your dashboards, templates, automations and scripts to use the replacement entity, then disable the deprecated sensor to have it removed after the next restart.", + "title": "Deprecated sensor detected" + }, + "deprecated_sensor_scripts": { + "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with `{replacement_entity_id}`.\n\nThe sensor was used in the following automations or scripts:\n{items}\n\nUpdate the above automations or scripts to use the replacement entity, then disable the deprecated sensor to have it removed after the next restart.", + "title": "[%key:component::jvc_projector::issues::deprecated_sensor::title%]" + } } } diff --git a/homeassistant/components/jvc_projector/util.py b/homeassistant/components/jvc_projector/util.py new file mode 100644 index 00000000000000..e37ceaab934176 --- /dev/null +++ b/homeassistant/components/jvc_projector/util.py @@ -0,0 +1,104 @@ +"""Utility helpers for the jvc_projector integration.""" + +from __future__ import annotations + +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) + +from .const import DOMAIN + + +def deprecate_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + platform_domain: str, + entity_unique_id: str, + issue_id: str, + issue_string: str, + replacement_entity_unique_id: str, + replacement_entity_id: str, + version: str = "2026.9.0", +) -> bool: + """Create an issue for deprecated entities.""" + if entity_id := entity_registry.async_get_entity_id( + platform_domain, DOMAIN, entity_unique_id + ): + entity_entry = entity_registry.async_get(entity_id) + if not entity_entry: + async_delete_issue(hass, DOMAIN, issue_id) + return False + + items = get_automations_and_scripts_using_entity(hass, entity_id) + if entity_entry.disabled and not items: + entity_registry.async_remove(entity_id) + async_delete_issue(hass, DOMAIN, issue_id) + return False + + translation_key = issue_string + placeholders = { + "entity_id": entity_id, + "entity_name": entity_entry.name or entity_entry.original_name or "Unknown", + "replacement_entity_id": ( + entity_registry.async_get_entity_id( + Platform.SELECT, DOMAIN, replacement_entity_unique_id + ) + or replacement_entity_id + ), + } + if items: + translation_key = f"{translation_key}_scripts" + placeholders["items"] = "\n".join(items) + + async_create_issue( + hass, + DOMAIN, + issue_id, + breaks_in_ha_version=version, + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=translation_key, + translation_placeholders=placeholders, + ) + return True + + async_delete_issue(hass, DOMAIN, issue_id) + return False + + +def get_automations_and_scripts_using_entity( + hass: HomeAssistant, + entity_id: str, +) -> list[str]: + """Get automations and scripts using an entity.""" + # These helpers return referencing automation/script entity IDs. + automations = automations_with_entity(hass, entity_id) + scripts = scripts_with_entity(hass, entity_id) + if not automations and not scripts: + return [] + + entity_registry = er.async_get(hass) + items: list[str] = [] + + for integration, entities in ( + ("automation", automations), + ("script", scripts), + ): + for used_entity_id in entities: + # Prefer entity-registry metadata so we can render edit links. + if item := entity_registry.async_get(used_entity_id): + items.append( + f"- [{item.original_name}](/config/{integration}/edit/{item.unique_id})" + ) + else: + # Keep unresolved references as plain text so they still count as usage. + items.append(f"- `{used_entity_id}`") + + return items diff --git a/homeassistant/components/kaiterra/air_quality.py b/homeassistant/components/kaiterra/air_quality.py index 97553d6bda6f2c..cdd9f3461ce2a2 100644 --- a/homeassistant/components/kaiterra/air_quality.py +++ b/homeassistant/components/kaiterra/air_quality.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + from homeassistant.components.air_quality import AirQualityEntity from homeassistant.const import CONF_DEVICE_ID, CONF_NAME from homeassistant.core import HomeAssistant @@ -104,7 +106,7 @@ def unique_id(self): return f"{self._device_id}_air_quality" @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the device state attributes.""" return { attr: value diff --git a/homeassistant/components/kaiterra/manifest.json b/homeassistant/components/kaiterra/manifest.json index 88651565cd003b..e74006755333f0 100644 --- a/homeassistant/components/kaiterra/manifest.json +++ b/homeassistant/components/kaiterra/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["kaiterra_async_client"], "quality_scale": "legacy", - "requirements": ["kaiterra-async-client==1.0.0"] + "requirements": ["kaiterra-async-client==1.1.0"] } diff --git a/homeassistant/components/kaleidescape/entity.py b/homeassistant/components/kaleidescape/entity.py index 1c391b6600b3ec..f9a67323f82a42 100644 --- a/homeassistant/components/kaleidescape/entity.py +++ b/homeassistant/components/kaleidescape/entity.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo @@ -44,7 +44,7 @@ async def async_added_to_hass(self) -> None: """Register update listener.""" @callback - def _update(event: str) -> None: + def _update(event: str, *args: Any) -> None: """Handle device state changes.""" self.async_write_ha_state() diff --git a/homeassistant/components/kaleidescape/manifest.json b/homeassistant/components/kaleidescape/manifest.json index ee607829b7affa..7ad51d60c56f1c 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.0.2"], + "requirements": ["pykaleidescape==1.1.3"], "ssdp": [ { "deviceType": "schemas-upnp-org:device:Basic:1", diff --git a/homeassistant/components/kankun/switch.py b/homeassistant/components/kankun/switch.py index 51bddebeb77c93..0543e45abaeee6 100644 --- a/homeassistant/components/kankun/switch.py +++ b/homeassistant/components/kankun/switch.py @@ -79,8 +79,8 @@ class KankunSwitch(SwitchEntity): def __init__(self, hass, name, host, port, path, user, passwd): """Initialize the device.""" self._hass = hass - self._name = name - self._state = False + self._attr_name = name + self._attr_is_on = False self._url = f"http://{host}:{port}{path}" if user is not None: self._auth = (user, passwd) @@ -109,26 +109,16 @@ def _query_state(self): except requests.RequestException: _LOGGER.error("State query failed") - @property - def name(self): - """Return the name of the switch.""" - return self._name - - @property - def is_on(self): - """Return true if device is on.""" - return self._state - def update(self) -> None: """Update device state.""" - self._state = self._query_state() + self._attr_is_on = self._query_state() def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" if self._switch("on"): - self._state = True + self._attr_is_on = True def turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" if self._switch("off"): - self._state = False + self._attr_is_on = False diff --git a/homeassistant/components/keenetic_ndms2/binary_sensor.py b/homeassistant/components/keenetic_ndms2/binary_sensor.py index 6eea55c33e7191..485d27abae2f34 100644 --- a/homeassistant/components/keenetic_ndms2/binary_sensor.py +++ b/homeassistant/components/keenetic_ndms2/binary_sensor.py @@ -34,7 +34,7 @@ def __init__(self, router: KeeneticRouter) -> None: self._attr_device_info = router.device_info @property - def is_on(self): + def is_on(self) -> bool: """Return true if the UPS is online, else false.""" return self._router.available diff --git a/homeassistant/components/keenetic_ndms2/device_tracker.py b/homeassistant/components/keenetic_ndms2/device_tracker.py index 7de7c497ef334d..94cdb13d79eb06 100644 --- a/homeassistant/components/keenetic_ndms2/device_tracker.py +++ b/homeassistant/components/keenetic_ndms2/device_tracker.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import Any from ndms2_client import Device @@ -126,7 +127,7 @@ def available(self) -> bool: return self._router.available @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the device state attributes.""" if self.is_connected: return { diff --git a/homeassistant/components/kef/media_player.py b/homeassistant/components/kef/media_player.py index 1c5188b1a6f399..c5f350e00cd96a 100644 --- a/homeassistant/components/kef/media_player.py +++ b/homeassistant/components/kef/media_player.py @@ -6,6 +6,7 @@ from functools import partial import ipaddress import logging +from typing import Any from aiokef import AsyncKefSpeaker from aiokef.aiokef import DSP_OPTION_MAPPING @@ -346,7 +347,7 @@ async def async_will_remove_from_hass(self) -> None: self._update_dsp_task_remover = None @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the DSP settings of the KEF device.""" return self._dsp or {} diff --git a/homeassistant/components/keyboard_remote/manifest.json b/homeassistant/components/keyboard_remote/manifest.json index f543ae72972b08..2159dd9d90eab6 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.6.1", "asyncinotify==4.2.0"] + "requirements": ["evdev==1.9.3", "asyncinotify==4.4.0"] } diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 15f7314ee7ac23..6bf5896dd70300 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -7,11 +7,16 @@ from __future__ import annotations import datetime +from functools import partial from random import random import voluptuous as vol -from homeassistant.components.labs import async_is_preview_feature_enabled, async_listen +from homeassistant.components.labs import ( + EventLabsUpdatedData, + async_is_preview_feature_enabled, + async_subscribe_preview_feature, +) from homeassistant.components.recorder import DOMAIN as RECORDER_DOMAIN, get_instance from homeassistant.components.recorder.models import ( StatisticData, @@ -51,7 +56,9 @@ COMPONENTS_WITH_DEMO_PLATFORM = [ Platform.BUTTON, + Platform.FAN, Platform.IMAGE, + Platform.INFRARED, Platform.LAWN_MOWER, Platform.LOCK, Platform.NOTIFY, @@ -126,22 +133,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Notify backup listeners hass.async_create_task(_notify_backup_listeners(hass), eager_start=False) + # Reload config entry when subentries are added/removed/updated + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + # Subscribe to labs feature updates for kitchen_sink preview repair entry.async_on_unload( - async_listen( + async_subscribe_preview_feature( hass, domain=DOMAIN, preview_feature="special_repair", - listener=lambda: _async_update_special_repair(hass), + listener=partial(_async_update_special_repair, hass), ) ) # Check if lab feature is currently enabled and create repair if so - _async_update_special_repair(hass) + await _async_update_special_repair(hass) return True +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload config entry on update (e.g. subentry added/removed).""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload config entry.""" # Notify backup listeners @@ -166,15 +181,22 @@ async def async_remove_config_entry_device( return True -@callback -def _async_update_special_repair(hass: HomeAssistant) -> None: +async def _async_update_special_repair( + hass: HomeAssistant, + event_data: EventLabsUpdatedData | None = None, +) -> None: """Create or delete the special repair issue. Creates a repair issue when the special_repair lab feature is enabled, and deletes it when disabled. This demonstrates how lab features can interact with Home Assistant's repair system. """ - if async_is_preview_feature_enabled(hass, DOMAIN, "special_repair"): + enabled = ( + event_data["enabled"] + if event_data is not None + else async_is_preview_feature_enabled(hass, DOMAIN, "special_repair") + ) + if enabled: async_create_issue( hass, DOMAIN, diff --git a/homeassistant/components/kitchen_sink/backup.py b/homeassistant/components/kitchen_sink/backup.py index 46b204845ada15..1ff9cc5e05d252 100644 --- a/homeassistant/components/kitchen_sink/backup.py +++ b/homeassistant/components/kitchen_sink/backup.py @@ -13,6 +13,7 @@ BackupAgent, BackupNotFound, Folder, + OnProgressCallback, ) from homeassistant.core import HomeAssistant, callback @@ -91,6 +92,7 @@ async def async_upload_backup( *, open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], backup: AgentBackup, + on_progress: OnProgressCallback, **kwargs: Any, ) -> None: """Upload a backup.""" diff --git a/homeassistant/components/kitchen_sink/config_flow.py b/homeassistant/components/kitchen_sink/config_flow.py index 27a10738f483fc..434d54dc1e5825 100644 --- a/homeassistant/components/kitchen_sink/config_flow.py +++ b/homeassistant/components/kitchen_sink/config_flow.py @@ -8,18 +8,23 @@ import voluptuous as vol from homeassistant import data_entry_flow +from homeassistant.components.infrared import ( + DOMAIN as INFRARED_DOMAIN, + async_get_emitters, +) from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, ConfigSubentryFlow, - OptionsFlowWithReload, + OptionsFlow, SubentryFlowResult, ) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig -from . import DOMAIN +from .const import CONF_INFRARED_ENTITY_ID, DOMAIN CONF_BOOLEAN = "bool" CONF_INT = "int" @@ -44,7 +49,10 @@ def async_get_supported_subentry_types( cls, config_entry: ConfigEntry ) -> dict[str, type[ConfigSubentryFlow]]: """Return subentries supported by this handler.""" - return {"entity": SubentryFlowHandler} + return { + "entity": SubentryFlowHandler, + "infrared_fan": InfraredFanSubentryFlowHandler, + } async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: """Set the config entry up from yaml.""" @@ -65,7 +73,7 @@ async def async_step_reauth_confirm( return self.async_abort(reason="reauth_successful") -class OptionsFlowHandler(OptionsFlowWithReload): +class OptionsFlowHandler(OptionsFlow): """Handle options.""" async def async_step_init( @@ -146,7 +154,7 @@ async def async_step_reconfigure_sensor( """Reconfigure a sensor.""" if user_input is not None: title = user_input.pop("name") - return self.async_update_reload_and_abort( + return self.async_update_and_abort( self._get_entry(), self._get_reconfigure_subentry(), data=user_input, @@ -162,3 +170,35 @@ async def async_step_reconfigure_sensor( } ), ) + + +class InfraredFanSubentryFlowHandler(ConfigSubentryFlow): + """Handle infrared fan subentry flow.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to add an infrared fan.""" + + entities = async_get_emitters(self.hass) + if not entities: + return self.async_abort(reason="no_emitters") + + if user_input is not None: + title = user_input.pop("name") + return self.async_create_entry(data=user_input, title=title) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required("name"): str, + vol.Required(CONF_INFRARED_ENTITY_ID): EntitySelector( + EntitySelectorConfig( + domain=INFRARED_DOMAIN, + include_entities=[entity.entity_id for entity in entities], + ) + ), + } + ), + ) diff --git a/homeassistant/components/kitchen_sink/const.py b/homeassistant/components/kitchen_sink/const.py index e6edaca46ce277..bce291bd5d661e 100644 --- a/homeassistant/components/kitchen_sink/const.py +++ b/homeassistant/components/kitchen_sink/const.py @@ -7,6 +7,7 @@ from homeassistant.util.hass_dict import HassKey DOMAIN = "kitchen_sink" +CONF_INFRARED_ENTITY_ID = "infrared_entity_id" DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( f"{DOMAIN}.backup_agent_listeners" ) diff --git a/homeassistant/components/kitchen_sink/fan.py b/homeassistant/components/kitchen_sink/fan.py new file mode 100644 index 00000000000000..db02da6930c27b --- /dev/null +++ b/homeassistant/components/kitchen_sink/fan.py @@ -0,0 +1,150 @@ +"""Demo platform that offers a fake infrared fan entity.""" + +from __future__ import annotations + +from typing import Any + +import infrared_protocols + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.components.infrared import async_send_command +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.event import async_track_state_change_event + +from .const import CONF_INFRARED_ENTITY_ID, DOMAIN + +PARALLEL_UPDATES = 0 + +DUMMY_FAN_ADDRESS = 0x1234 +DUMMY_CMD_POWER_ON = 0x01 +DUMMY_CMD_POWER_OFF = 0x02 +DUMMY_CMD_SPEED_LOW = 0x03 +DUMMY_CMD_SPEED_MEDIUM = 0x04 +DUMMY_CMD_SPEED_HIGH = 0x05 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the demo infrared fan platform.""" + for subentry_id, subentry in config_entry.subentries.items(): + if subentry.subentry_type != "infrared_fan": + continue + async_add_entities( + [ + DemoInfraredFan( + subentry_id=subentry_id, + device_name=subentry.title, + infrared_entity_id=subentry.data[CONF_INFRARED_ENTITY_ID], + ) + ], + config_subentry_id=subentry_id, + ) + + +class DemoInfraredFan(FanEntity): + """Representation of a demo infrared fan entity.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_should_poll = False + _attr_assumed_state = True + _attr_speed_count = 3 + _attr_supported_features = ( + FanEntityFeature.SET_SPEED + | FanEntityFeature.TURN_OFF + | FanEntityFeature.TURN_ON + ) + + def __init__( + self, + subentry_id: str, + device_name: str, + infrared_entity_id: str, + ) -> None: + """Initialize the demo infrared fan entity.""" + self._infrared_entity_id = infrared_entity_id + self._attr_unique_id = subentry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, subentry_id)}, + name=device_name, + ) + self._attr_percentage = 0 + + async def async_added_to_hass(self) -> None: + """Subscribe to infrared entity state changes.""" + await super().async_added_to_hass() + + @callback + def _async_ir_state_changed(event: Event[EventStateChangedData]) -> None: + """Handle infrared entity state changes.""" + new_state = event.data["new_state"] + self._attr_available = ( + new_state is not None and new_state.state != STATE_UNAVAILABLE + ) + self.async_write_ha_state() + + self.async_on_remove( + async_track_state_change_event( + self.hass, [self._infrared_entity_id], _async_ir_state_changed + ) + ) + + # Set initial availability based on current infrared entity state + ir_state = self.hass.states.get(self._infrared_entity_id) + self._attr_available = ( + ir_state is not None and ir_state.state != STATE_UNAVAILABLE + ) + + async def _send_command(self, command_code: int) -> None: + """Send an IR command using the NEC protocol.""" + command = infrared_protocols.NECCommand( + address=DUMMY_FAN_ADDRESS, + command=command_code, + modulation=38000, + ) + await async_send_command( + self.hass, self._infrared_entity_id, command, context=self._context + ) + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the fan.""" + if percentage is not None: + await self.async_set_percentage(percentage) + return + await self._send_command(DUMMY_CMD_POWER_ON) + self._attr_percentage = 33 + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the fan.""" + await self._send_command(DUMMY_CMD_POWER_OFF) + self._attr_percentage = 0 + self.async_write_ha_state() + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" + if percentage == 0: + await self.async_turn_off() + return + + if percentage <= 33: + await self._send_command(DUMMY_CMD_SPEED_LOW) + elif percentage <= 66: + await self._send_command(DUMMY_CMD_SPEED_MEDIUM) + else: + await self._send_command(DUMMY_CMD_SPEED_HIGH) + + self._attr_percentage = percentage + self.async_write_ha_state() diff --git a/homeassistant/components/kitchen_sink/infrared.py b/homeassistant/components/kitchen_sink/infrared.py new file mode 100644 index 00000000000000..4f93c9be0c59ee --- /dev/null +++ b/homeassistant/components/kitchen_sink/infrared.py @@ -0,0 +1,65 @@ +"""Demo platform that offers a fake infrared entity.""" + +from __future__ import annotations + +import infrared_protocols + +from homeassistant.components import persistent_notification +from homeassistant.components.infrared import InfraredEntity +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 infrared platform.""" + async_add_entities( + [ + DemoInfrared( + unique_id="ir_transmitter", + device_name="IR Blaster", + entity_name="Infrared Transmitter", + ), + ] + ) + + +class DemoInfrared(InfraredEntity): + """Representation of a demo infrared entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__( + self, + unique_id: str, + device_name: str, + entity_name: str, + ) -> None: + """Initialize the demo infrared entity.""" + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=device_name, + ) + self._attr_name = entity_name + + 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" + ) diff --git a/homeassistant/components/kitchen_sink/sensor.py b/homeassistant/components/kitchen_sink/sensor.py index 04cb833f0df844..15f73b781bc446 100644 --- a/homeassistant/components/kitchen_sink/sensor.py +++ b/homeassistant/components/kitchen_sink/sensor.py @@ -101,6 +101,8 @@ async def async_setup_entry( ) for subentry_id, subentry in config_entry.subentries.items(): + if subentry.subentry_type != "entity": + continue async_add_entities( [ DemoSensor( diff --git a/homeassistant/components/kitchen_sink/strings.json b/homeassistant/components/kitchen_sink/strings.json index 107bd1f509b0fa..15305d711b26a1 100644 --- a/homeassistant/components/kitchen_sink/strings.json +++ b/homeassistant/components/kitchen_sink/strings.json @@ -32,6 +32,24 @@ "description": "Reconfigure the sensor" } } + }, + "infrared_fan": { + "abort": { + "no_emitters": "No infrared transmitter entities found. Please set up an infrared device first." + }, + "entry_type": "Infrared fan", + "initiate_flow": { + "user": "Add infrared fan" + }, + "step": { + "user": { + "data": { + "infrared_entity_id": "Infrared transmitter", + "name": "[%key:common::config_flow::data::name%]" + }, + "description": "Select an infrared transmitter to control the fan." + } + } } }, "device": { diff --git a/homeassistant/components/kmtronic/switch.py b/homeassistant/components/kmtronic/switch.py index f8d068cec8769c..c1becf1e9d47d5 100644 --- a/homeassistant/components/kmtronic/switch.py +++ b/homeassistant/components/kmtronic/switch.py @@ -56,7 +56,7 @@ def __init__(self, hub, coordinator, relay, reverse, config_entry_id): self._attr_unique_id = f"{config_entry_id}_relay{relay.id}" @property - def is_on(self): + def is_on(self) -> bool: """Return entity state.""" if self._reverse: return not self._relay.is_energised diff --git a/homeassistant/components/knx/dpt.py b/homeassistant/components/knx/dpt.py index 9d76313d7013ca..b07e5046db7b7b 100644 --- a/homeassistant/components/knx/dpt.py +++ b/homeassistant/components/knx/dpt.py @@ -8,6 +8,7 @@ from xknx.dpt.dpt_16 import DPTString from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import UnitOfReactiveEnergy HaDptClass = Literal["numeric", "enum", "complex", "string"] @@ -36,7 +37,7 @@ def get_supported_dpts() -> Mapping[str, DPTInfo]: main=dpt_class.dpt_main_number, # type: ignore[typeddict-item] # checked in xknx unit tests sub=dpt_class.dpt_sub_number, name=dpt_class.value_type, - unit=dpt_class.unit, + unit=_sensor_unit_overrides.get(dpt_number_str, dpt_class.unit), sensor_device_class=_sensor_device_classes.get(dpt_number_str), sensor_state_class=_get_sensor_state_class(ha_dpt_class, dpt_number_str), ) @@ -77,13 +78,13 @@ def _ha_dpt_class(dpt_cls: type[DPTBase]) -> HaDptClass: "12.1200": SensorDeviceClass.VOLUME, "12.1201": SensorDeviceClass.VOLUME, "13.002": SensorDeviceClass.VOLUME_FLOW_RATE, - "13.010": SensorDeviceClass.ENERGY, - "13.012": SensorDeviceClass.REACTIVE_ENERGY, - "13.013": SensorDeviceClass.ENERGY, - "13.015": SensorDeviceClass.REACTIVE_ENERGY, - "13.016": SensorDeviceClass.ENERGY, - "13.1200": SensorDeviceClass.VOLUME, - "13.1201": SensorDeviceClass.VOLUME, + "13.010": SensorDeviceClass.ENERGY, # DPTActiveEnergy + "13.012": SensorDeviceClass.REACTIVE_ENERGY, # DPTReactiveEnergy + "13.013": SensorDeviceClass.ENERGY, # DPTActiveEnergykWh + "13.015": SensorDeviceClass.REACTIVE_ENERGY, # DPTReactiveEnergykVARh + "13.016": SensorDeviceClass.ENERGY, # DPTActiveEnergyMWh + "13.1200": SensorDeviceClass.VOLUME, # DPTDeltaVolumeLiquidLitre + "13.1201": SensorDeviceClass.VOLUME, # DPTDeltaVolumeM3 "14.010": SensorDeviceClass.AREA, "14.019": SensorDeviceClass.CURRENT, "14.027": SensorDeviceClass.VOLTAGE, @@ -91,7 +92,7 @@ def _ha_dpt_class(dpt_cls: type[DPTBase]) -> HaDptClass: "14.030": SensorDeviceClass.VOLTAGE, "14.031": SensorDeviceClass.ENERGY, "14.033": SensorDeviceClass.FREQUENCY, - "14.037": SensorDeviceClass.ENERGY_STORAGE, + "14.037": SensorDeviceClass.ENERGY_STORAGE, # DPTHeatQuantity "14.039": SensorDeviceClass.DISTANCE, "14.051": SensorDeviceClass.WEIGHT, "14.056": SensorDeviceClass.POWER, @@ -101,7 +102,7 @@ def _ha_dpt_class(dpt_cls: type[DPTBase]) -> HaDptClass: "14.068": SensorDeviceClass.TEMPERATURE, "14.069": SensorDeviceClass.TEMPERATURE, "14.070": SensorDeviceClass.TEMPERATURE_DELTA, - "14.076": SensorDeviceClass.VOLUME, + "14.076": SensorDeviceClass.VOLUME, # DPTVolume "14.077": SensorDeviceClass.VOLUME_FLOW_RATE, "14.080": SensorDeviceClass.APPARENT_POWER, "14.1200": SensorDeviceClass.VOLUME_FLOW_RATE, @@ -121,17 +122,28 @@ def _ha_dpt_class(dpt_cls: type[DPTBase]) -> HaDptClass: "13.010": SensorStateClass.TOTAL, # DPTActiveEnergy "13.011": SensorStateClass.TOTAL, # DPTApparantEnergy "13.012": SensorStateClass.TOTAL, # DPTReactiveEnergy + "13.013": SensorStateClass.TOTAL, # DPTActiveEnergykWh + "13.015": SensorStateClass.TOTAL, # DPTReactiveEnergykVARh + "13.016": SensorStateClass.TOTAL, # DPTActiveEnergyMWh + "13.1200": SensorStateClass.TOTAL, # DPTDeltaVolumeLiquidLitre + "13.1201": SensorStateClass.TOTAL, # DPTDeltaVolumeM3 "14.007": SensorStateClass.MEASUREMENT_ANGLE, # DPTAngleDeg - "14.037": SensorStateClass.TOTAL, # DPTHeatQuantity "14.051": SensorStateClass.TOTAL, # DPTMass "14.055": SensorStateClass.MEASUREMENT_ANGLE, # DPTPhaseAngleDeg "14.031": SensorStateClass.TOTAL_INCREASING, # DPTEnergy + "14.076": SensorStateClass.TOTAL, # DPTVolume "17.001": None, # DPTSceneNumber "29.010": SensorStateClass.TOTAL, # DPTActiveEnergy8Byte "29.011": SensorStateClass.TOTAL, # DPTApparantEnergy8Byte "29.012": SensorStateClass.TOTAL, # DPTReactiveEnergy8Byte } +_sensor_unit_overrides: Mapping[str, str] = { + "13.012": UnitOfReactiveEnergy.VOLT_AMPERE_REACTIVE_HOUR, # DPTReactiveEnergy (VARh in KNX) + "13.015": UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR, # DPTReactiveEnergykVARh (kVARh in KNX) + "29.012": UnitOfReactiveEnergy.VOLT_AMPERE_REACTIVE_HOUR, # DPTReactiveEnergy8Byte (VARh in KNX) +} + def _get_sensor_state_class( ha_dpt_class: HaDptClass, dpt_number_str: str diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 75bcf8b9704f75..a431ab98fefd69 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -11,9 +11,9 @@ "loggers": ["xknx", "xknxproject"], "quality_scale": "platinum", "requirements": [ - "xknx==3.14.0", + "xknx==3.15.0", "xknxproject==3.8.2", - "knx-frontend==2026.2.13.222258" + "knx-frontend==2026.3.2.183756" ], "single_config_entry": true } diff --git a/homeassistant/components/knx/number.py b/homeassistant/components/knx/number.py index 645715dc6aac3c..c8079dc583a3f0 100644 --- a/homeassistant/components/knx/number.py +++ b/homeassistant/components/knx/number.py @@ -120,6 +120,19 @@ def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: value_type=config[CONF_TYPE], ), ) + dpt_string = self._device.sensor_value.dpt_class.dpt_number_str() + dpt_info = get_supported_dpts()[dpt_string] + + self._attr_device_class = config.get( + CONF_DEVICE_CLASS, + try_parse_enum( + # sensor device classes should, with some exceptions ("enum" etc.), align with number device classes + NumberDeviceClass, + dpt_info["sensor_device_class"], + ), + ) + self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) + self._attr_mode = config[CONF_MODE] self._attr_native_max_value = config.get( NumberConf.MAX, self._device.sensor_value.dpt_class.value_max, @@ -128,14 +141,16 @@ def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: NumberConf.MIN, self._device.sensor_value.dpt_class.value_min, ) - self._attr_mode = config[CONF_MODE] self._attr_native_step = config.get( NumberConf.STEP, self._device.sensor_value.dpt_class.resolution, ) - self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) + self._attr_native_unit_of_measurement = config.get( + CONF_UNIT_OF_MEASUREMENT, + dpt_info["unit"], + ) self._attr_unique_id = str(self._device.sensor_value.group_address) - self._attr_native_unit_of_measurement = self._device.unit_of_measurement() + self._device.sensor_value.value = max(0, self._attr_native_min_value) diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index e5db0e650bdd57..2498f5ca4e1fce 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -20,9 +20,12 @@ from homeassistant.components.cover import ( DEVICE_CLASSES_SCHEMA as COVER_DEVICE_CLASSES_SCHEMA, ) -from homeassistant.components.number import NumberMode +from homeassistant.components.number import ( + DEVICE_CLASSES_SCHEMA as NUMBER_DEVICE_CLASSES_SCHEMA, + NumberMode, +) from homeassistant.components.sensor import ( - CONF_STATE_CLASS, + CONF_STATE_CLASS as CONF_SENSOR_STATE_CLASS, DEVICE_CLASSES_SCHEMA as SENSOR_DEVICE_CLASSES_SCHEMA, STATE_CLASSES_SCHEMA, ) @@ -39,6 +42,7 @@ CONF_NAME, CONF_PAYLOAD, CONF_TYPE, + CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, Platform, ) @@ -64,6 +68,7 @@ NumberConf, SceneConf, ) +from .dpt import get_supported_dpts from .validation import ( backwards_compatible_xknx_climate_enum_member, dpt_base_type_validator, @@ -74,6 +79,7 @@ string_type_validator, sync_state_validator, validate_number_attributes, + validate_sensor_attributes, ) @@ -143,6 +149,13 @@ def select_options_sub_validator(entity_config: OrderedDict) -> OrderedDict: return entity_config +def _sensor_attribute_sub_validator(config: dict) -> dict: + """Validate that state_class is compatible with device_class and unit_of_measurement.""" + transcoder: type[DPTBase] = DPTBase.parse_transcoder(config[CONF_TYPE]) # type: ignore[assignment] # already checked in sensor_type_validator + dpt_metadata = get_supported_dpts()[transcoder.dpt_number_str()] + return validate_sensor_attributes(dpt_metadata, config) + + ######### # EVENT ######### @@ -778,6 +791,8 @@ class NumberSchema(KNXPlatformSchema): vol.Optional(NumberConf.MAX): vol.Coerce(float), vol.Optional(NumberConf.MIN): vol.Coerce(float), vol.Optional(NumberConf.STEP): cv.positive_float, + vol.Optional(CONF_DEVICE_CLASS): NUMBER_DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, } ), @@ -848,17 +863,21 @@ class SensorSchema(KNXPlatformSchema): CONF_SYNC_STATE = CONF_SYNC_STATE DEFAULT_NAME = "KNX Sensor" - ENTITY_SCHEMA = vol.Schema( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, - vol.Optional(CONF_ALWAYS_CALLBACK, default=False): cv.boolean, - vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, - vol.Required(CONF_TYPE): sensor_type_validator, - vol.Required(CONF_STATE_ADDRESS): ga_list_validator, - vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, - } + ENTITY_SCHEMA = vol.All( + vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, + vol.Optional(CONF_ALWAYS_CALLBACK, default=False): cv.boolean, + vol.Optional(CONF_SENSOR_STATE_CLASS): STATE_CLASSES_SCHEMA, + vol.Required(CONF_TYPE): sensor_type_validator, + vol.Required(CONF_STATE_ADDRESS): ga_list_validator, + vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, + } + ), + _sensor_attribute_sub_validator, ) diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index 0d5480858026df..113964980f371d 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -213,19 +213,25 @@ def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: value_type=config[CONF_TYPE], ), ) - if device_class := config.get(CONF_DEVICE_CLASS): - self._attr_device_class = device_class - else: - self._attr_device_class = try_parse_enum( - SensorDeviceClass, self._device.ha_device_class() - ) + dpt_string = self._device.sensor_value.dpt_class.dpt_number_str() + dpt_info = get_supported_dpts()[dpt_string] - self._attr_force_update = config[SensorSchema.CONF_ALWAYS_CALLBACK] + self._attr_device_class = config.get( + CONF_DEVICE_CLASS, + dpt_info["sensor_device_class"], + ) self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) - self._attr_unique_id = str(self._device.sensor_value.group_address_state) - self._attr_native_unit_of_measurement = self._device.unit_of_measurement() - self._attr_state_class = config.get(CONF_STATE_CLASS) self._attr_extra_state_attributes = {} + self._attr_force_update = config[SensorSchema.CONF_ALWAYS_CALLBACK] + self._attr_native_unit_of_measurement = config.get( + CONF_UNIT_OF_MEASUREMENT, + dpt_info["unit"], + ) + self._attr_state_class = config.get( + CONF_STATE_CLASS, + dpt_info["sensor_state_class"], + ) + self._attr_unique_id = str(self._device.sensor_value.group_address_state) class KnxUiSensor(_KnxSensor, KnxUiEntity): diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index cef993ca355a1c..c1b5d77c63f390 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -13,9 +13,7 @@ ) from homeassistant.components.sensor import ( CONF_STATE_CLASS as CONF_SENSOR_STATE_CLASS, - DEVICE_CLASS_STATE_CLASSES, DEVICE_CLASS_UNITS as SENSOR_DEVICE_CLASS_UNITS, - STATE_CLASS_UNITS, SensorDeviceClass, SensorStateClass, ) @@ -52,7 +50,7 @@ SceneConf, ) from ..dpt import get_supported_dpts -from ..validation import validate_number_attributes +from ..validation import validate_number_attributes, validate_sensor_attributes from .const import ( CONF_ALWAYS_CALLBACK, CONF_COLOR, @@ -684,62 +682,11 @@ class ConfClimateFanSpeedMode(StrEnum): ) -def _validate_sensor_attributes(config: dict) -> dict: +def _sensor_attribute_sub_validator(config: dict) -> dict: """Validate that state_class is compatible with device_class and unit_of_measurement.""" dpt = config[CONF_GA_SENSOR][CONF_DPT] dpt_metadata = get_supported_dpts()[dpt] - state_class = config.get( - CONF_SENSOR_STATE_CLASS, - dpt_metadata["sensor_state_class"], - ) - device_class = config.get( - CONF_DEVICE_CLASS, - dpt_metadata["sensor_device_class"], - ) - unit_of_measurement = config.get( - CONF_UNIT_OF_MEASUREMENT, - dpt_metadata["unit"], - ) - if ( - state_class - and device_class - and (state_classes := DEVICE_CLASS_STATE_CLASSES.get(device_class)) is not None - and state_class not in state_classes - ): - raise vol.Invalid( - f"State class '{state_class}' is not valid for device class '{device_class}'. " - f"Valid options are: {', '.join(sorted(map(str, state_classes), key=str.casefold))}", - path=[CONF_SENSOR_STATE_CLASS], - ) - if ( - device_class - and (d_c_units := SENSOR_DEVICE_CLASS_UNITS.get(device_class)) is not None - and unit_of_measurement not in d_c_units - ): - raise vol.Invalid( - f"Unit of measurement '{unit_of_measurement}' is not valid for device class '{device_class}'. " - f"Valid options are: {', '.join(sorted(map(str, d_c_units), key=str.casefold))}", - path=( - [CONF_DEVICE_CLASS] - if CONF_DEVICE_CLASS in config - else [CONF_UNIT_OF_MEASUREMENT] - ), - ) - if ( - state_class - and (s_c_units := STATE_CLASS_UNITS.get(state_class)) is not None - and unit_of_measurement not in s_c_units - ): - raise vol.Invalid( - f"Unit of measurement '{unit_of_measurement}' is not valid for state class '{state_class}'. " - f"Valid options are: {', '.join(sorted(map(str, s_c_units), key=str.casefold))}", - path=( - [CONF_SENSOR_STATE_CLASS] - if CONF_SENSOR_STATE_CLASS in config - else [CONF_UNIT_OF_MEASUREMENT] - ), - ) - return config + return validate_sensor_attributes(dpt_metadata, config) SENSOR_KNX_SCHEMA = AllSerializeFirst( @@ -788,7 +735,7 @@ def _validate_sensor_attributes(config: dict) -> dict: ), }, ), - _validate_sensor_attributes, + _sensor_attribute_sub_validator, ) KNX_SCHEMA_FOR_PLATFORM = { diff --git a/homeassistant/components/knx/telegrams.py b/homeassistant/components/knx/telegrams.py index 1f01c9c78feb76..1aa75aa1141aac 100644 --- a/homeassistant/components/knx/telegrams.py +++ b/homeassistant/components/knx/telegrams.py @@ -45,6 +45,7 @@ class TelegramDict(DecodedTelegramPayload): """Represent a Telegram as a dict.""" # this has to be in sync with the frontend implementation + data_secure: bool | None destination: str destination_name: str direction: str @@ -153,6 +154,7 @@ def telegram_to_dict(self, telegram: Telegram) -> TelegramDict: value = _serializable_decoded_data(telegram.decoded_data.value) return TelegramDict( + data_secure=telegram.data_secure, destination=f"{telegram.destination_address}", destination_name=dst_name, direction=telegram.direction.value, diff --git a/homeassistant/components/knx/validation.py b/homeassistant/components/knx/validation.py index 280ffc6b967859..f218dec0faea49 100644 --- a/homeassistant/components/knx/validation.py +++ b/homeassistant/components/knx/validation.py @@ -14,11 +14,17 @@ from homeassistant.components.number import ( DEVICE_CLASS_UNITS as NUMBER_DEVICE_CLASS_UNITS, ) +from homeassistant.components.sensor import ( + CONF_STATE_CLASS as CONF_SENSOR_STATE_CLASS, + DEVICE_CLASS_STATE_CLASSES, + DEVICE_CLASS_UNITS, + STATE_CLASS_UNITS, +) from homeassistant.const import CONF_DEVICE_CLASS, CONF_UNIT_OF_MEASUREMENT from homeassistant.helpers import config_validation as cv from .const import NumberConf -from .dpt import get_supported_dpts +from .dpt import DPTInfo, get_supported_dpts def dpt_subclass_validator(dpt_base_class: type[DPTBase]) -> Callable[[Any], str | int]: @@ -219,3 +225,65 @@ def validate_number_attributes( ) return config + + +def validate_sensor_attributes( + dpt_info: DPTInfo, config: dict[str, Any] +) -> dict[str, Any]: + """Validate that state_class is compatible with device_class and unit_of_measurement. + + Works for both, UI and YAML configuration schema since they + share same names for all tested attributes. + """ + state_class = config.get( + CONF_SENSOR_STATE_CLASS, + dpt_info["sensor_state_class"], + ) + device_class = config.get( + CONF_DEVICE_CLASS, + dpt_info["sensor_device_class"], + ) + unit_of_measurement = config.get( + CONF_UNIT_OF_MEASUREMENT, + dpt_info["unit"], + ) + if ( + state_class + and device_class + and (state_classes := DEVICE_CLASS_STATE_CLASSES.get(device_class)) is not None + and state_class not in state_classes + ): + raise vol.Invalid( + f"State class '{state_class}' is not valid for device class '{device_class}'. " + f"Valid options are: {', '.join(sorted(map(str, state_classes), key=str.casefold))}", + path=[CONF_SENSOR_STATE_CLASS], + ) + if ( + device_class + and (d_c_units := DEVICE_CLASS_UNITS.get(device_class)) is not None + and unit_of_measurement not in d_c_units + ): + raise vol.Invalid( + f"Unit of measurement '{unit_of_measurement}' is not valid for device class '{device_class}'. " + f"Valid options are: {', '.join(sorted(map(str, d_c_units), key=str.casefold))}", + path=( + [CONF_DEVICE_CLASS] + if CONF_DEVICE_CLASS in config + else [CONF_UNIT_OF_MEASUREMENT] + ), + ) + if ( + state_class + and (s_c_units := STATE_CLASS_UNITS.get(state_class)) is not None + and unit_of_measurement not in s_c_units + ): + raise vol.Invalid( + f"Unit of measurement '{unit_of_measurement}' is not valid for state class '{state_class}'. " + f"Valid options are: {', '.join(sorted(map(str, s_c_units), key=str.casefold))}", + path=( + [CONF_SENSOR_STATE_CLASS] + if CONF_SENSOR_STATE_CLASS in config + else [CONF_UNIT_OF_MEASUREMENT] + ), + ) + return config diff --git a/homeassistant/components/kodi/browse_media.py b/homeassistant/components/kodi/browse_media.py index b62379aaa253c9..aa98ca7e8be741 100644 --- a/homeassistant/components/kodi/browse_media.py +++ b/homeassistant/components/kodi/browse_media.py @@ -219,7 +219,7 @@ async def library_payload(hass): ) for child in library_info.children: - child.thumbnail = "https://brands.home-assistant.io/_/kodi/logo.png" + child.thumbnail = "/api/brands/integration/kodi/logo.png" with contextlib.suppress(BrowseError): item = await media_source.async_browse_media( diff --git a/homeassistant/components/kostal_plenticore/number.py b/homeassistant/components/kostal_plenticore/number.py index ddb0a84a6cc906..05da93f30acdab 100644 --- a/homeassistant/components/kostal_plenticore/number.py +++ b/homeassistant/components/kostal_plenticore/number.py @@ -67,6 +67,22 @@ class PlenticoreNumberEntityDescription(NumberEntityDescription): fmt_from="format_round", fmt_to="format_round_back", ), + PlenticoreNumberEntityDescription( + key="active_power_limitation", + device_class=NumberDeviceClass.POWER, + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + icon="mdi:solar-power", + name="Active Power Limitation", + native_unit_of_measurement=UnitOfPower.WATT, + native_max_value=10000, + native_min_value=0, + native_step=1, + module_id="devices:local", + data_id="Inverter:ActivePowerLimitation", + fmt_from="format_round", + fmt_to="format_round_back", + ), ] diff --git a/homeassistant/components/kraken/__init__.py b/homeassistant/components/kraken/__init__.py index ccdd704d9df19d..065b647a971c48 100644 --- a/homeassistant/components/kraken/__init__.py +++ b/homeassistant/components/kraken/__init__.py @@ -2,35 +2,16 @@ from __future__ import annotations -import asyncio -from datetime import timedelta -import logging - -import krakenex -import pykrakenapi - 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 homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ( - CONF_TRACKED_ASSET_PAIRS, - DEFAULT_SCAN_INTERVAL, - DEFAULT_TRACKED_ASSET_PAIR, - DISPATCH_CONFIG_UPDATED, - DOMAIN, - KrakenResponse, -) -from .utils import get_tradable_asset_pairs - -CALL_RATE_LIMIT_SLEEP = 1 +from .const import DISPATCH_CONFIG_UPDATED, DOMAIN +from .coordinator import KrakenData PLATFORMS = [Platform.SENSOR] -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up kraken from a config entry.""" @@ -53,111 +34,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return unload_ok -class KrakenData: - """Define an object to hold kraken data.""" - - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: - """Initialize.""" - self._hass = hass - self._config_entry = config_entry - self._api = pykrakenapi.KrakenAPI(krakenex.API(), retry=0, crl_sleep=0) - self.tradable_asset_pairs: dict[str, str] = {} - self.coordinator: DataUpdateCoordinator[KrakenResponse | None] | None = None - - async def async_update(self) -> KrakenResponse | None: - """Get the latest data from the Kraken.com REST API. - - All tradeable asset pairs are retrieved, not the tracked asset pairs - selected by the user. This enables us to check for an unknown and - thus likely removed asset pair in sensor.py and only log a warning - once. - """ - try: - async with asyncio.timeout(10): - return await self._hass.async_add_executor_job(self._get_kraken_data) - except pykrakenapi.pykrakenapi.KrakenAPIError as error: - if "Unknown asset pair" in str(error): - _LOGGER.warning( - "Kraken.com reported an unknown asset pair. Refreshing list of" - " tradable asset pairs" - ) - await self._async_refresh_tradable_asset_pairs() - else: - raise UpdateFailed( - f"Unable to fetch data from Kraken.com: {error}" - ) from error - except pykrakenapi.pykrakenapi.CallRateLimitError: - _LOGGER.warning( - "Exceeded the Kraken.com call rate limit. Increase the update interval" - " to prevent this error" - ) - return None - - def _get_kraken_data(self) -> KrakenResponse: - websocket_name_pairs = self._get_websocket_name_asset_pairs() - ticker_df = self._api.get_ticker_information(websocket_name_pairs) - # Rename columns to their full name - ticker_df = ticker_df.rename( - columns={ - "a": "ask", - "b": "bid", - "c": "last_trade_closed", - "v": "volume", - "p": "volume_weighted_average", - "t": "number_of_trades", - "l": "low", - "h": "high", - "o": "opening_price", - } - ) - response_dict: KrakenResponse = ticker_df.transpose().to_dict() - return response_dict - - async def _async_refresh_tradable_asset_pairs(self) -> None: - self.tradable_asset_pairs = await self._hass.async_add_executor_job( - get_tradable_asset_pairs, self._api - ) - - async def async_setup(self) -> None: - """Set up the Kraken integration.""" - if not self._config_entry.options: - options = { - CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, - CONF_TRACKED_ASSET_PAIRS: [DEFAULT_TRACKED_ASSET_PAIR], - } - self._hass.config_entries.async_update_entry( - self._config_entry, options=options - ) - await self._async_refresh_tradable_asset_pairs() - # Wait 1 second to avoid triggering the KrakenAPI CallRateLimiter - await asyncio.sleep(CALL_RATE_LIMIT_SLEEP) - self.coordinator = DataUpdateCoordinator( - self._hass, - _LOGGER, - name=DOMAIN, - config_entry=self._config_entry, - update_method=self.async_update, - update_interval=timedelta( - seconds=self._config_entry.options[CONF_SCAN_INTERVAL] - ), - ) - await self.coordinator.async_config_entry_first_refresh() - # Wait 1 second to avoid triggering the KrakenAPI CallRateLimiter - await asyncio.sleep(CALL_RATE_LIMIT_SLEEP) - - def _get_websocket_name_asset_pairs(self) -> str: - return ",".join( - pair - for tracked_pair in self._config_entry.options[CONF_TRACKED_ASSET_PAIRS] - if (pair := self.tradable_asset_pairs.get(tracked_pair)) is not None - ) - - def set_update_interval(self, update_interval: int) -> None: - """Set the coordinator update_interval to the supplied update_interval.""" - if self.coordinator is not None: - self.coordinator.update_interval = timedelta(seconds=update_interval) - - async def async_options_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Triggered by config entry options updates.""" hass.data[DOMAIN].set_update_interval(config_entry.options[CONF_SCAN_INTERVAL]) diff --git a/homeassistant/components/kraken/coordinator.py b/homeassistant/components/kraken/coordinator.py new file mode 100644 index 00000000000000..c222e58ba15ddb --- /dev/null +++ b/homeassistant/components/kraken/coordinator.py @@ -0,0 +1,133 @@ +"""Coordinator for the kraken integration.""" + +from __future__ import annotations + +import asyncio +from datetime import timedelta +import logging + +import krakenex +import pykrakenapi + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONF_TRACKED_ASSET_PAIRS, + DEFAULT_SCAN_INTERVAL, + DEFAULT_TRACKED_ASSET_PAIR, + DOMAIN, + KrakenResponse, +) +from .utils import get_tradable_asset_pairs + +CALL_RATE_LIMIT_SLEEP = 1 + +_LOGGER = logging.getLogger(__name__) + + +class KrakenData: + """Define an object to hold kraken data.""" + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize.""" + self._hass = hass + self._config_entry = config_entry + self._api = pykrakenapi.KrakenAPI(krakenex.API(), retry=0, crl_sleep=0) + self.tradable_asset_pairs: dict[str, str] = {} + self.coordinator: DataUpdateCoordinator[KrakenResponse | None] | None = None + + async def async_update(self) -> KrakenResponse | None: + """Get the latest data from the Kraken.com REST API. + + All tradeable asset pairs are retrieved, not the tracked asset pairs + selected by the user. This enables us to check for an unknown and + thus likely removed asset pair in sensor.py and only log a warning + once. + """ + try: + async with asyncio.timeout(10): + return await self._hass.async_add_executor_job(self._get_kraken_data) + except pykrakenapi.pykrakenapi.KrakenAPIError as error: + if "Unknown asset pair" in str(error): + _LOGGER.warning( + "Kraken.com reported an unknown asset pair. Refreshing list of" + " tradable asset pairs" + ) + await self._async_refresh_tradable_asset_pairs() + else: + raise UpdateFailed( + f"Unable to fetch data from Kraken.com: {error}" + ) from error + except pykrakenapi.pykrakenapi.CallRateLimitError: + _LOGGER.warning( + "Exceeded the Kraken.com call rate limit. Increase the update interval" + " to prevent this error" + ) + return None + + def _get_kraken_data(self) -> KrakenResponse: + websocket_name_pairs = self._get_websocket_name_asset_pairs() + ticker_df = self._api.get_ticker_information(websocket_name_pairs) + # Rename columns to their full name + ticker_df = ticker_df.rename( + columns={ + "a": "ask", + "b": "bid", + "c": "last_trade_closed", + "v": "volume", + "p": "volume_weighted_average", + "t": "number_of_trades", + "l": "low", + "h": "high", + "o": "opening_price", + } + ) + response_dict: KrakenResponse = ticker_df.transpose().to_dict() + return response_dict + + async def _async_refresh_tradable_asset_pairs(self) -> None: + self.tradable_asset_pairs = await self._hass.async_add_executor_job( + get_tradable_asset_pairs, self._api + ) + + async def async_setup(self) -> None: + """Set up the Kraken integration.""" + if not self._config_entry.options: + options = { + CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, + CONF_TRACKED_ASSET_PAIRS: [DEFAULT_TRACKED_ASSET_PAIR], + } + self._hass.config_entries.async_update_entry( + self._config_entry, options=options + ) + await self._async_refresh_tradable_asset_pairs() + # Wait 1 second to avoid triggering the KrakenAPI CallRateLimiter + await asyncio.sleep(CALL_RATE_LIMIT_SLEEP) + self.coordinator = DataUpdateCoordinator( + self._hass, + _LOGGER, + name=DOMAIN, + config_entry=self._config_entry, + update_method=self.async_update, + update_interval=timedelta( + seconds=self._config_entry.options[CONF_SCAN_INTERVAL] + ), + ) + await self.coordinator.async_config_entry_first_refresh() + # Wait 1 second to avoid triggering the KrakenAPI CallRateLimiter + await asyncio.sleep(CALL_RATE_LIMIT_SLEEP) + + def _get_websocket_name_asset_pairs(self) -> str: + return ",".join( + pair + for tracked_pair in self._config_entry.options[CONF_TRACKED_ASSET_PAIRS] + if (pair := self.tradable_asset_pairs.get(tracked_pair)) is not None + ) + + def set_update_interval(self, update_interval: int) -> None: + """Set the coordinator update_interval to the supplied update_interval.""" + if self.coordinator is not None: + self.coordinator.update_interval = timedelta(seconds=update_interval) diff --git a/homeassistant/components/kraken/sensor.py b/homeassistant/components/kraken/sensor.py index 8d5f9ab65af9b3..f301a54ee07cea 100644 --- a/homeassistant/components/kraken/sensor.py +++ b/homeassistant/components/kraken/sensor.py @@ -22,13 +22,13 @@ DataUpdateCoordinator, ) -from . import KrakenData from .const import ( CONF_TRACKED_ASSET_PAIRS, DISPATCH_CONFIG_UPDATED, DOMAIN, KrakenResponse, ) +from .coordinator import KrakenData _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/labs/helpers.py b/homeassistant/components/labs/helpers.py index 81454cbe811aa0..2045487ec8463f 100644 --- a/homeassistant/components/labs/helpers.py +++ b/homeassistant/components/labs/helpers.py @@ -7,6 +7,7 @@ from homeassistant.const import EVENT_LABS_UPDATED from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers.frame import report_usage from .const import LABS_DATA from .models import EventLabsUpdatedData @@ -79,6 +80,8 @@ def async_listen( ) -> Callable[[], None]: """Listen for changes to a specific preview feature. + Deprecated: use async_subscribe_preview_feature instead. + Args: hass: HomeAssistant instance domain: Integration domain @@ -88,6 +91,11 @@ def async_listen( Returns: Callable to unsubscribe from the listener """ + report_usage( + "calls `async_listen` which is deprecated, " + "use `async_subscribe_preview_feature` instead", + breaks_in_ha_version="2027.3.0", + ) async def _listener(_event_data: EventLabsUpdatedData) -> None: listener() diff --git a/homeassistant/components/launch_library/__init__.py b/homeassistant/components/launch_library/__init__.py index 6bfd3bc9adf9f4..9b29af194e7db9 100644 --- a/homeassistant/components/launch_library/__init__.py +++ b/homeassistant/components/launch_library/__init__.py @@ -2,61 +2,20 @@ from __future__ import annotations -from datetime import timedelta -import logging -from typing import TypedDict - -from pylaunches import PyLaunches, PyLaunchesError -from pylaunches.types import Launch, StarshipResponse - from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .coordinator import LaunchLibraryCoordinator PLATFORMS = [Platform.SENSOR] -class LaunchLibraryData(TypedDict): - """Typed dict representation of data returned from pylaunches.""" - - upcoming_launches: list[Launch] - starship_events: StarshipResponse - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up this integration using UI.""" - hass.data.setdefault(DOMAIN, {}) - - session = async_get_clientsession(hass) - launches = PyLaunches(session) - - async def async_update() -> LaunchLibraryData: - try: - return LaunchLibraryData( - upcoming_launches=await launches.launch_upcoming( - filters={"limit": 1, "hide_recent_previous": "True"}, - ), - starship_events=await launches.dashboard_starship(), - ) - except PyLaunchesError as ex: - raise UpdateFailed(ex) from ex - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name=DOMAIN, - update_method=async_update, - update_interval=timedelta(hours=1), - ) - + coordinator = LaunchLibraryCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() hass.data[DOMAIN] = coordinator diff --git a/homeassistant/components/launch_library/coordinator.py b/homeassistant/components/launch_library/coordinator.py new file mode 100644 index 00000000000000..b88bc105630ddf --- /dev/null +++ b/homeassistant/components/launch_library/coordinator.py @@ -0,0 +1,60 @@ +"""DataUpdateCoordinator for the launch_library integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import TypedDict + +from pylaunches import PyLaunches, PyLaunchesError +from pylaunches.types import Launch, StarshipResponse + +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, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class LaunchLibraryData(TypedDict): + """Typed dict representation of data returned from pylaunches.""" + + upcoming_launches: list[Launch] + starship_events: StarshipResponse + + +class LaunchLibraryCoordinator(DataUpdateCoordinator[LaunchLibraryData]): + """Class to manage fetching Launch Library data.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=timedelta(hours=1), + ) + session = async_get_clientsession(hass) + self._launches = PyLaunches(session) + + async def _async_update_data(self) -> LaunchLibraryData: + """Fetch data from Launch Library.""" + try: + return LaunchLibraryData( + upcoming_launches=await self._launches.launch_upcoming( + filters={"limit": 1, "hide_recent_previous": "True"}, + ), + starship_events=await self._launches.dashboard_starship(), + ) + except PyLaunchesError as ex: + raise UpdateFailed(ex) from ex diff --git a/homeassistant/components/launch_library/diagnostics.py b/homeassistant/components/launch_library/diagnostics.py index 75541598ef511a..d96d5fed7f54fc 100644 --- a/homeassistant/components/launch_library/diagnostics.py +++ b/homeassistant/components/launch_library/diagnostics.py @@ -8,10 +8,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import LaunchLibraryData from .const import DOMAIN +from .coordinator import LaunchLibraryCoordinator async def async_get_config_entry_diagnostics( @@ -20,7 +19,7 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: DataUpdateCoordinator[LaunchLibraryData] = hass.data[DOMAIN] + coordinator: LaunchLibraryCoordinator = hass.data[DOMAIN] if coordinator.data is None: return {} diff --git a/homeassistant/components/launch_library/sensor.py b/homeassistant/components/launch_library/sensor.py index 201b4c8f0370e0..e844744c83463f 100644 --- a/homeassistant/components/launch_library/sensor.py +++ b/homeassistant/components/launch_library/sensor.py @@ -19,14 +19,11 @@ 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, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import parse_datetime -from . import LaunchLibraryData from .const import DOMAIN +from .coordinator import LaunchLibraryCoordinator DEFAULT_NEXT_LAUNCH_NAME = "Next launch" @@ -126,7 +123,7 @@ async def async_setup_entry( ) -> None: """Set up the sensor platform.""" name = entry.data.get(CONF_NAME, DEFAULT_NEXT_LAUNCH_NAME) - coordinator: DataUpdateCoordinator[LaunchLibraryData] = hass.data[DOMAIN] + coordinator: LaunchLibraryCoordinator = hass.data[DOMAIN] async_add_entities( LaunchLibrarySensor( @@ -139,9 +136,7 @@ async def async_setup_entry( ) -class LaunchLibrarySensor( - CoordinatorEntity[DataUpdateCoordinator[LaunchLibraryData]], SensorEntity -): +class LaunchLibrarySensor(CoordinatorEntity[LaunchLibraryCoordinator], SensorEntity): """Representation of the next launch sensors.""" _attr_attribution = "Data provided by Launch Library." @@ -151,7 +146,7 @@ class LaunchLibrarySensor( def __init__( self, - coordinator: DataUpdateCoordinator[LaunchLibraryData], + coordinator: LaunchLibraryCoordinator, entry_id: str, description: LaunchLibrarySensorEntityDescription, name: str, diff --git a/homeassistant/components/laundrify/sensor.py b/homeassistant/components/laundrify/sensor.py index 7caa6a9b04442f..d939bb7ab6d455 100644 --- a/homeassistant/components/laundrify/sensor.py +++ b/homeassistant/components/laundrify/sensor.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from .const import DOMAIN, MANUFACTURER, MODELS from .coordinator import LaundrifyConfigEntry, LaundrifyUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -47,7 +47,14 @@ class LaundrifyBaseSensor(SensorEntity): def __init__(self, device: LaundrifyDevice) -> None: """Initialize the sensor.""" self._device = device - self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device.id)}) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.id)}, + name=device.name, + manufacturer=MANUFACTURER, + model=MODELS[device.model], + sw_version=device.firmwareVersion, + configuration_url=f"http://{device.internalIP}", + ) self._attr_unique_id = f"{device.id}_{self._attr_device_class}" diff --git a/homeassistant/components/lawn_mower/icons.json b/homeassistant/components/lawn_mower/icons.json index 2a3ab0383b1675..1602bff56d662a 100644 --- a/homeassistant/components/lawn_mower/icons.json +++ b/homeassistant/components/lawn_mower/icons.json @@ -44,6 +44,9 @@ }, "started_mowing": { "trigger": "mdi:play" + }, + "started_returning": { + "trigger": "mdi:home-import-outline" } } } diff --git a/homeassistant/components/lawn_mower/strings.json b/homeassistant/components/lawn_mower/strings.json index 35cf8f5d1615eb..0ca9ace458e038 100644 --- a/homeassistant/components/lawn_mower/strings.json +++ b/homeassistant/components/lawn_mower/strings.json @@ -139,6 +139,16 @@ } }, "name": "Lawn mower started mowing" + }, + "started_returning": { + "description": "Triggers after one or more lawn mowers start returning to dock.", + "fields": { + "behavior": { + "description": "[%key:component::lawn_mower::common::trigger_behavior_description%]", + "name": "[%key:component::lawn_mower::common::trigger_behavior_name%]" + } + }, + "name": "Lawn mower started returning to dock" } } } diff --git a/homeassistant/components/lawn_mower/trigger.py b/homeassistant/components/lawn_mower/trigger.py index 7bfcf0ea31e22e..35e09f7175e876 100644 --- a/homeassistant/components/lawn_mower/trigger.py +++ b/homeassistant/components/lawn_mower/trigger.py @@ -12,6 +12,9 @@ "started_mowing": make_entity_target_state_trigger( DOMAIN, LawnMowerActivity.MOWING ), + "started_returning": make_entity_target_state_trigger( + DOMAIN, LawnMowerActivity.RETURNING + ), } diff --git a/homeassistant/components/lawn_mower/triggers.yaml b/homeassistant/components/lawn_mower/triggers.yaml index dc076f361cec89..bc3cb321cf8e76 100644 --- a/homeassistant/components/lawn_mower/triggers.yaml +++ b/homeassistant/components/lawn_mower/triggers.yaml @@ -18,3 +18,4 @@ docked: *trigger_common errored: *trigger_common paused_mowing: *trigger_common started_mowing: *trigger_common +started_returning: *trigger_common diff --git a/homeassistant/components/lcn/binary_sensor.py b/homeassistant/components/lcn/binary_sensor.py index 4f813ca4c00206..889bbaff5421b7 100644 --- a/homeassistant/components/lcn/binary_sensor.py +++ b/homeassistant/components/lcn/binary_sensor.py @@ -7,7 +7,7 @@ import pypck from homeassistant.components.binary_sensor import ( - DOMAIN as DOMAIN_BINARY_SENSOR, + DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorEntity, ) from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES, CONF_SOURCE @@ -48,14 +48,14 @@ async def async_setup_entry( ) config_entry.runtime_data.add_entities_callbacks.update( - {DOMAIN_BINARY_SENSOR: add_entities} + {BINARY_SENSOR_DOMAIN: add_entities} ) add_entities( ( entity_config for entity_config in config_entry.data[CONF_ENTITIES] - if entity_config[CONF_DOMAIN] == DOMAIN_BINARY_SENSOR + if entity_config[CONF_DOMAIN] == BINARY_SENSOR_DOMAIN ), ) diff --git a/homeassistant/components/lcn/climate.py b/homeassistant/components/lcn/climate.py index 260c9bd3bf0292..aa633adf100173 100644 --- a/homeassistant/components/lcn/climate.py +++ b/homeassistant/components/lcn/climate.py @@ -8,7 +8,7 @@ import pypck from homeassistant.components.climate import ( - DOMAIN as DOMAIN_CLIMATE, + DOMAIN as CLIMATE_DOMAIN, ClimateEntity, ClimateEntityFeature, HVACMode, @@ -66,14 +66,14 @@ async def async_setup_entry( ) config_entry.runtime_data.add_entities_callbacks.update( - {DOMAIN_CLIMATE: add_entities} + {CLIMATE_DOMAIN: add_entities} ) add_entities( ( entity_config for entity_config in config_entry.data[CONF_ENTITIES] - if entity_config[CONF_DOMAIN] == DOMAIN_CLIMATE + if entity_config[CONF_DOMAIN] == CLIMATE_DOMAIN ), ) diff --git a/homeassistant/components/lcn/cover.py b/homeassistant/components/lcn/cover.py index 4066cef747fd55..ea2c1e6d82bc5b 100644 --- a/homeassistant/components/lcn/cover.py +++ b/homeassistant/components/lcn/cover.py @@ -9,7 +9,7 @@ from homeassistant.components.cover import ( ATTR_POSITION, - DOMAIN as DOMAIN_COVER, + DOMAIN as COVER_DOMAIN, CoverEntity, CoverEntityFeature, ) @@ -60,14 +60,14 @@ async def async_setup_entry( ) config_entry.runtime_data.add_entities_callbacks.update( - {DOMAIN_COVER: add_entities} + {COVER_DOMAIN: add_entities} ) add_entities( ( entity_config for entity_config in config_entry.data[CONF_ENTITIES] - if entity_config[CONF_DOMAIN] == DOMAIN_COVER + if entity_config[CONF_DOMAIN] == COVER_DOMAIN ), ) diff --git a/homeassistant/components/lcn/light.py b/homeassistant/components/lcn/light.py index be6ac6935cdad8..b29f7fd2a00b5c 100644 --- a/homeassistant/components/lcn/light.py +++ b/homeassistant/components/lcn/light.py @@ -10,7 +10,7 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_TRANSITION, - DOMAIN as DOMAIN_LIGHT, + DOMAIN as LIGHT_DOMAIN, ColorMode, LightEntity, LightEntityFeature, @@ -66,14 +66,14 @@ async def async_setup_entry( ) config_entry.runtime_data.add_entities_callbacks.update( - {DOMAIN_LIGHT: add_entities} + {LIGHT_DOMAIN: add_entities} ) add_entities( ( entity_config for entity_config in config_entry.data[CONF_ENTITIES] - if entity_config[CONF_DOMAIN] == DOMAIN_LIGHT + if entity_config[CONF_DOMAIN] == LIGHT_DOMAIN ), ) diff --git a/homeassistant/components/lcn/scene.py b/homeassistant/components/lcn/scene.py index e2089cda950c51..e8c09ec10815fe 100644 --- a/homeassistant/components/lcn/scene.py +++ b/homeassistant/components/lcn/scene.py @@ -6,7 +6,7 @@ import pypck -from homeassistant.components.scene import DOMAIN as DOMAIN_SCENE, Scene +from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN, Scene from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES, CONF_SCENE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -51,14 +51,14 @@ async def async_setup_entry( ) config_entry.runtime_data.add_entities_callbacks.update( - {DOMAIN_SCENE: add_entities} + {SCENE_DOMAIN: add_entities} ) add_entities( ( entity_config for entity_config in config_entry.data[CONF_ENTITIES] - if entity_config[CONF_DOMAIN] == DOMAIN_SCENE + if entity_config[CONF_DOMAIN] == SCENE_DOMAIN ), ) diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index 3515d6ab5f574c..6b5c8bbfead5a3 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -8,7 +8,7 @@ import pypck from homeassistant.components.sensor import ( - DOMAIN as DOMAIN_SENSOR, + DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, ) @@ -102,14 +102,14 @@ async def async_setup_entry( ) config_entry.runtime_data.add_entities_callbacks.update( - {DOMAIN_SENSOR: add_entities} + {SENSOR_DOMAIN: add_entities} ) add_entities( ( entity_config for entity_config in config_entry.data[CONF_ENTITIES] - if entity_config[CONF_DOMAIN] == DOMAIN_SENSOR + if entity_config[CONF_DOMAIN] == SENSOR_DOMAIN ), ) diff --git a/homeassistant/components/lcn/switch.py b/homeassistant/components/lcn/switch.py index c18c92215a95c3..2a71080c643b2d 100644 --- a/homeassistant/components/lcn/switch.py +++ b/homeassistant/components/lcn/switch.py @@ -7,7 +7,7 @@ import pypck -from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH, SwitchEntity +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -56,14 +56,14 @@ async def async_setup_entry( ) config_entry.runtime_data.add_entities_callbacks.update( - {DOMAIN_SWITCH: add_entities} + {SWITCH_DOMAIN: add_entities} ) add_entities( ( entity_config for entity_config in config_entry.data[CONF_ENTITIES] - if entity_config[CONF_DOMAIN] == DOMAIN_SWITCH + if entity_config[CONF_DOMAIN] == SWITCH_DOMAIN ), ) diff --git a/homeassistant/components/led_ble/__init__.py b/homeassistant/components/led_ble/__init__.py index 7f89ab202acb45..82c67159a7ba90 100644 --- a/homeassistant/components/led_ble/__init__.py +++ b/homeassistant/components/led_ble/__init__.py @@ -3,25 +3,20 @@ from __future__ import annotations import asyncio -from datetime import timedelta -import logging -from led_ble import BLEAK_EXCEPTIONS, LEDBLE +from led_ble import LEDBLE from homeassistant.components import bluetooth from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DEVICE_TIMEOUT, UPDATE_SECONDS -from .models import LEDBLEConfigEntry, LEDBLEData +from .const import DEVICE_TIMEOUT +from .coordinator import LEDBLEConfigEntry, LEDBLECoordinator, LEDBLEData PLATFORMS: list[Platform] = [Platform.LIGHT] -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry(hass: HomeAssistant, entry: LEDBLEConfigEntry) -> bool: """Set up LED BLE from a config entry.""" @@ -53,23 +48,9 @@ def _async_update_ble( ) ) - async def _async_update() -> None: - """Update the device state.""" - try: - await led_ble.update() - except BLEAK_EXCEPTIONS as ex: - raise UpdateFailed(str(ex)) from ex - startup_event = asyncio.Event() cancel_first_update = led_ble.register_callback(lambda *_: startup_event.set()) - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name=led_ble.name, - update_method=_async_update, - update_interval=timedelta(seconds=UPDATE_SECONDS), - ) + coordinator = LEDBLECoordinator(hass, entry, led_ble) try: await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/led_ble/coordinator.py b/homeassistant/components/led_ble/coordinator.py new file mode 100644 index 00000000000000..c4bbf758167844 --- /dev/null +++ b/homeassistant/components/led_ble/coordinator.py @@ -0,0 +1,58 @@ +"""The LED BLE coordinator.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging + +from led_ble import BLEAK_EXCEPTIONS, LEDBLE + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import UPDATE_SECONDS + +type LEDBLEConfigEntry = ConfigEntry[LEDBLEData] + + +@dataclass +class LEDBLEData: + """Data for the led ble integration.""" + + title: str + device: LEDBLE + coordinator: LEDBLECoordinator + + +_LOGGER = logging.getLogger(__name__) + + +class LEDBLECoordinator(DataUpdateCoordinator[None]): + """Class to manage fetching LED BLE data.""" + + config_entry: LEDBLEConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: LEDBLEConfigEntry, + led_ble: LEDBLE, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=led_ble.name, + update_interval=timedelta(seconds=UPDATE_SECONDS), + ) + self.led_ble = led_ble + + async def _async_update_data(self) -> None: + """Update the device state.""" + try: + await self.led_ble.update() + except BLEAK_EXCEPTIONS as ex: + raise UpdateFailed(str(ex)) from ex diff --git a/homeassistant/components/led_ble/light.py b/homeassistant/components/led_ble/light.py index 89263555a1ed3d..8ffc31582f9a48 100644 --- a/homeassistant/components/led_ble/light.py +++ b/homeassistant/components/led_ble/light.py @@ -19,13 +19,10 @@ 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.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_EFFECT_SPEED -from .models import LEDBLEConfigEntry +from .coordinator import LEDBLEConfigEntry, LEDBLECoordinator async def async_setup_entry( @@ -38,7 +35,7 @@ async def async_setup_entry( async_add_entities([LEDBLEEntity(data.coordinator, data.device, entry.title)]) -class LEDBLEEntity(CoordinatorEntity[DataUpdateCoordinator[None]], LightEntity): +class LEDBLEEntity(CoordinatorEntity[LEDBLECoordinator], LightEntity): """Representation of LEDBLE device.""" _attr_supported_color_modes = {ColorMode.RGB, ColorMode.WHITE} @@ -47,7 +44,7 @@ class LEDBLEEntity(CoordinatorEntity[DataUpdateCoordinator[None]], LightEntity): _attr_supported_features = LightEntityFeature.EFFECT def __init__( - self, coordinator: DataUpdateCoordinator[None], device: LEDBLE, name: str + self, coordinator: LEDBLECoordinator, device: LEDBLE, name: str ) -> None: """Initialize an ledble light.""" super().__init__(coordinator) diff --git a/homeassistant/components/led_ble/models.py b/homeassistant/components/led_ble/models.py deleted file mode 100644 index 077aa9ee7ea6f9..00000000000000 --- a/homeassistant/components/led_ble/models.py +++ /dev/null @@ -1,21 +0,0 @@ -"""The led ble integration models.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from led_ble import LEDBLE - -from homeassistant.config_entries import ConfigEntry -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -type LEDBLEConfigEntry = ConfigEntry[LEDBLEData] - - -@dataclass -class LEDBLEData: - """Data for the led ble integration.""" - - title: str - device: LEDBLE - coordinator: DataUpdateCoordinator[None] diff --git a/homeassistant/components/libre_hardware_monitor/__init__.py b/homeassistant/components/libre_hardware_monitor/__init__.py index 2a94cda9bac2f2..5f4b50353523e0 100644 --- a/homeassistant/components/libre_hardware_monitor/__init__.py +++ b/homeassistant/components/libre_hardware_monitor/__init__.py @@ -6,7 +6,11 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from .const import DOMAIN from .coordinator import ( @@ -80,6 +84,21 @@ async def async_setup_entry( lhm_coordinator = LibreHardwareMonitorCoordinator(hass, config_entry) await lhm_coordinator.async_config_entry_first_refresh() + if lhm_coordinator.data.is_deprecated_version: + issue_id = f"deprecated_api_{config_entry.entry_id}" + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + breaks_in_ha_version="2026.9.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_api", + translation_placeholders={ + "lhm_releases_url": "https://github.com/LibreHardwareMonitor/LibreHardwareMonitor/releases" + }, + ) + config_entry.runtime_data = lhm_coordinator await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) diff --git a/homeassistant/components/libre_hardware_monitor/coordinator.py b/homeassistant/components/libre_hardware_monitor/coordinator.py index 2e68541c3e82c9..7c24fb753c1dac 100644 --- a/homeassistant/components/libre_hardware_monitor/coordinator.py +++ b/homeassistant/components/libre_hardware_monitor/coordinator.py @@ -21,7 +21,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME 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, issue_registry as ir from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -50,7 +50,7 @@ def __init__( config_entry=config_entry, update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), ) - + self._entry_id = config_entry.entry_id self._api = LibreHardwareMonitorClient( host=config_entry.data[CONF_HOST], port=config_entry.data[CONF_PORT], @@ -59,13 +59,16 @@ def __init__( session=async_create_clientsession(hass), ) device_entries: list[DeviceEntry] = dr.async_entries_for_config_entry( - registry=dr.async_get(self.hass), config_entry_id=config_entry.entry_id + registry=dr.async_get(self.hass), config_entry_id=self._entry_id ) self._previous_devices: dict[DeviceId, DeviceName] = { - DeviceId(next(iter(device.identifiers))[1]): DeviceName(device.name) + DeviceId( + next(iter(device.identifiers))[1].removeprefix(f"{self._entry_id}_") + ): DeviceName(device.name) for device in device_entries if device.identifiers and device.name } + self._is_deprecated_version: bool | None = None async def _async_update_data(self) -> LibreHardwareMonitorData: try: @@ -80,6 +83,12 @@ async def _async_update_data(self) -> LibreHardwareMonitorData: except LibreHardwareMonitorNoDevicesError as err: raise UpdateFailed("No sensor data available, will retry") from err + # Check whether user has upgraded LHM from a deprecated version while the integration is running + if self._is_deprecated_version and not lhm_data.is_deprecated_version: + # Clear deprecation issue + ir.async_delete_issue(self.hass, DOMAIN, f"deprecated_api_{self._entry_id}") + self._is_deprecated_version = lhm_data.is_deprecated_version + await self._async_handle_changes_in_devices( dict(lhm_data.main_device_ids_and_names) ) @@ -102,11 +111,6 @@ async def _async_handle_changes_in_devices( self, detected_devices: dict[DeviceId, DeviceName] ) -> None: """Handle device changes by deleting devices from / adding devices to Home Assistant.""" - detected_devices = { - DeviceId(f"{self.config_entry.entry_id}_{detected_id}"): device_name - for detected_id, device_name in detected_devices.items() - } - previous_device_ids = set(self._previous_devices.keys()) detected_device_ids = set(detected_devices.keys()) @@ -124,25 +128,14 @@ async def _async_handle_changes_in_devices( device_registry = dr.async_get(self.hass) for device_id in orphaned_devices: if device := device_registry.async_get_device( - identifiers={(DOMAIN, device_id)} + identifiers={(DOMAIN, f"{self._entry_id}_{device_id}")} ): _LOGGER.debug( "Removing device: %s", self._previous_devices[device_id] ) device_registry.async_update_device( device_id=device.id, - remove_config_entry_id=self.config_entry.entry_id, + remove_config_entry_id=self._entry_id, ) - if self.data is None: - # initial update during integration startup - self._previous_devices = detected_devices # type: ignore[unreachable] - return - - if new_devices := detected_device_ids - previous_device_ids: - _LOGGER.warning( - "New Device(s) detected, reload integration to add them to Home Assistant: %s", - [detected_devices[DeviceId(device_id)] for device_id in new_devices], - ) - self._previous_devices = detected_devices diff --git a/homeassistant/components/libre_hardware_monitor/diagnostics.py b/homeassistant/components/libre_hardware_monitor/diagnostics.py new file mode 100644 index 00000000000000..96bf2aaab78d50 --- /dev/null +++ b/homeassistant/components/libre_hardware_monitor/diagnostics.py @@ -0,0 +1,38 @@ +"""Diagnostics support for Libre Hardware Monitor.""" + +from __future__ import annotations + +from dataclasses import asdict, replace +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .coordinator import LibreHardwareMonitorConfigEntry, LibreHardwareMonitorData + +TO_REDACT = {CONF_USERNAME, CONF_PASSWORD} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: LibreHardwareMonitorConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + lhm_data: LibreHardwareMonitorData = config_entry.runtime_data.data + + return { + "config_entry_data": { + **async_redact_data(dict(config_entry.data), TO_REDACT), + }, + "lhm_data": _as_dict(lhm_data), + } + + +def _as_dict(data: LibreHardwareMonitorData) -> dict[str, Any]: + return asdict( + replace( + data, + main_device_ids_and_names=dict(data.main_device_ids_and_names), # type: ignore[arg-type] + sensor_data=dict(data.sensor_data), # type: ignore[arg-type] + ) + ) diff --git a/homeassistant/components/libre_hardware_monitor/manifest.json b/homeassistant/components/libre_hardware_monitor/manifest.json index 7a5873fec60efa..cfd02029be70b0 100644 --- a/homeassistant/components/libre_hardware_monitor/manifest.json +++ b/homeassistant/components/libre_hardware_monitor/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["librehardwaremonitor-api==1.9.1"] + "requirements": ["librehardwaremonitor-api==1.11.1"] } diff --git a/homeassistant/components/libre_hardware_monitor/quality_scale.yaml b/homeassistant/components/libre_hardware_monitor/quality_scale.yaml index 9d2cbc2986e16c..946163ad336091 100644 --- a/homeassistant/components/libre_hardware_monitor/quality_scale.yaml +++ b/homeassistant/components/libre_hardware_monitor/quality_scale.yaml @@ -49,9 +49,13 @@ rules: test-coverage: done # Gold devices: done - diagnostics: todo - discovery-update-info: todo - discovery: todo + diagnostics: done + discovery-update-info: + status: exempt + comment: Device can't be discovered + discovery: + status: exempt + comment: Device can't be discovered docs-data-update: todo docs-examples: todo docs-known-limitations: todo diff --git a/homeassistant/components/libre_hardware_monitor/sensor.py b/homeassistant/components/libre_hardware_monitor/sensor.py index c56bb75fc10759..a48fb6d4de6a88 100644 --- a/homeassistant/components/libre_hardware_monitor/sensor.py +++ b/homeassistant/components/libre_hardware_monitor/sensor.py @@ -2,9 +2,11 @@ from __future__ import annotations +import logging from typing import Any -from librehardwaremonitor_api.model import LibreHardwareMonitorSensorData +from librehardwaremonitor_api.model import DeviceId, LibreHardwareMonitorSensorData +from librehardwaremonitor_api.sensor_type import SensorType from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.core import HomeAssistant, callback @@ -15,6 +17,8 @@ from . import LibreHardwareMonitorConfigEntry, LibreHardwareMonitorCoordinator from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + PARALLEL_UPDATES = 0 STATE_MIN_VALUE = "min_value" @@ -29,10 +33,28 @@ async def async_setup_entry( """Set up the LibreHardwareMonitor platform.""" lhm_coordinator = config_entry.runtime_data - async_add_entities( - LibreHardwareMonitorSensor(lhm_coordinator, config_entry.entry_id, sensor_data) - for sensor_data in lhm_coordinator.data.sensor_data.values() - ) + known_devices: set[DeviceId] = set() + + def _check_device() -> None: + current_devices = set(lhm_coordinator.data.main_device_ids_and_names) + new_devices = current_devices - known_devices + if new_devices: + _LOGGER.debug("New Device(s) detected, adding: %s", new_devices) + known_devices.update(new_devices) + new_devices_sensor_data = [ + sensor_data + for sensor_data in lhm_coordinator.data.sensor_data.values() + if sensor_data.device_id in new_devices + ] + async_add_entities( + LibreHardwareMonitorSensor( + lhm_coordinator, config_entry.entry_id, sensor_data + ) + for sensor_data in new_devices_sensor_data + ) + + _check_device() + config_entry.async_on_unload(lhm_coordinator.async_add_listener(_check_device)) class LibreHardwareMonitorSensor( @@ -53,12 +75,8 @@ def __init__( super().__init__(coordinator) self._attr_name: str = sensor_data.name - self._attr_native_value: str | None = sensor_data.value - self._attr_extra_state_attributes: dict[str, Any] = { - STATE_MIN_VALUE: sensor_data.min, - STATE_MAX_VALUE: sensor_data.max, - } - self._attr_native_unit_of_measurement = sensor_data.unit + + self._set_state(coordinator.data.is_deprecated_version, sensor_data) self._attr_unique_id: str = f"{entry_id}_{sensor_data.sensor_id}" self._sensor_id: str = sensor_data.sensor_id @@ -70,15 +88,36 @@ def __init__( model=sensor_data.device_type, ) + def _set_state( + self, + is_deprecated_lhm_version: bool, + sensor_data: LibreHardwareMonitorSensorData, + ) -> None: + value = sensor_data.value + min_value = sensor_data.min + max_value = sensor_data.max + unit = sensor_data.unit + + if not is_deprecated_lhm_version and sensor_data.type == SensorType.THROUGHPUT: + # Temporary fix: convert the B/s value to KB/s to not break existing entries + # This will be migrated properly once SensorDeviceClass is introduced + value = f"{(float(value) / 1024):.1f}" if value else None + min_value = f"{(float(min_value) / 1024):.1f}" if min_value else None + max_value = f"{(float(max_value) / 1024):.1f}" if max_value else None + unit = "KB/s" + + self._attr_native_value: str | None = value + self._attr_extra_state_attributes: dict[str, Any] = { + STATE_MIN_VALUE: min_value, + STATE_MAX_VALUE: max_value, + } + self._attr_native_unit_of_measurement = unit + @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" if sensor_data := self.coordinator.data.sensor_data.get(self._sensor_id): - self._attr_native_value = sensor_data.value - self._attr_extra_state_attributes = { - STATE_MIN_VALUE: sensor_data.min, - STATE_MAX_VALUE: sensor_data.max, - } + self._set_state(self.coordinator.data.is_deprecated_version, sensor_data) else: self._attr_native_value = None diff --git a/homeassistant/components/libre_hardware_monitor/strings.json b/homeassistant/components/libre_hardware_monitor/strings.json index c5ff86e446c06d..a029a818ab9484 100644 --- a/homeassistant/components/libre_hardware_monitor/strings.json +++ b/homeassistant/components/libre_hardware_monitor/strings.json @@ -33,5 +33,11 @@ } } } + }, + "issues": { + "deprecated_api": { + "description": "Your version of Libre Hardware Monitor is deprecated and may not provide stable sensor data. To fix this issue:\n\n1. Download version 0.9.5 or later from {lhm_releases_url}\n2. Close Libre Hardware Monitor on your computer\n3. Install or extract the new version and start Libre Hardware Monitor again (you might have to re-enable the remote web server)\n4. Home Assistant will detect the new version and this issue will clear automatically", + "title": "Deprecated Libre Hardware Monitor version" + } } } diff --git a/homeassistant/components/lichess/__init__.py b/homeassistant/components/lichess/__init__.py new file mode 100644 index 00000000000000..2e76d6ed2b13f1 --- /dev/null +++ b/homeassistant/components/lichess/__init__.py @@ -0,0 +1,31 @@ +"""The Lichess integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import LichessConfigEntry, LichessCoordinator + +_PLATFORMS: list[Platform] = [ + Platform.SENSOR, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: LichessConfigEntry) -> bool: + """Set up Lichess from a config entry.""" + + coordinator = LichessCoordinator(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: LichessConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/lichess/config_flow.py b/homeassistant/components/lichess/config_flow.py new file mode 100644 index 00000000000000..3cc71b389e26cc --- /dev/null +++ b/homeassistant/components/lichess/config_flow.py @@ -0,0 +1,52 @@ +"""Config flow for the Lichess integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from aiolichess import AioLichess +from aiolichess.exceptions import AioLichessError, AuthError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_TOKEN +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class LichessConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Lichess.""" + + 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: + session = async_get_clientsession(self.hass) + client = AioLichess(session=session) + try: + user = await client.get_all(token=user_input[CONF_API_TOKEN]) + except AuthError: + errors["base"] = "invalid_auth" + except AioLichessError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + username = user.username + player_id = user.id + await self.async_set_unique_id(player_id) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=username, data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}), + errors=errors, + ) diff --git a/homeassistant/components/lichess/const.py b/homeassistant/components/lichess/const.py new file mode 100644 index 00000000000000..26b116653e476a --- /dev/null +++ b/homeassistant/components/lichess/const.py @@ -0,0 +1,3 @@ +"""Constants for the Lichess integration.""" + +DOMAIN = "lichess" diff --git a/homeassistant/components/lichess/coordinator.py b/homeassistant/components/lichess/coordinator.py new file mode 100644 index 00000000000000..1111d157cf04bb --- /dev/null +++ b/homeassistant/components/lichess/coordinator.py @@ -0,0 +1,44 @@ +"""Coordinator for Lichess.""" + +from datetime import timedelta +import logging + +from aiolichess import AioLichess +from aiolichess.exceptions import AioLichessError +from aiolichess.models import LichessStatistics + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + +type LichessConfigEntry = ConfigEntry[LichessCoordinator] + + +class LichessCoordinator(DataUpdateCoordinator[LichessStatistics]): + """Coordinator for Lichess.""" + + config_entry: LichessConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: LichessConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=config_entry.title, + update_interval=timedelta(hours=1), + ) + self.client = AioLichess(session=async_get_clientsession(hass)) + + async def _async_update_data(self) -> LichessStatistics: + """Update data for Lichess.""" + try: + return await self.client.get_statistics( + token=self.config_entry.data[CONF_API_TOKEN] + ) + except AioLichessError as err: + raise UpdateFailed("Error in communicating with Lichess") from err diff --git a/homeassistant/components/lichess/entity.py b/homeassistant/components/lichess/entity.py new file mode 100644 index 00000000000000..1f6dec10fb2426 --- /dev/null +++ b/homeassistant/components/lichess/entity.py @@ -0,0 +1,26 @@ +"""Base entity for Lichess integration.""" + +from typing import TYPE_CHECKING + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import LichessCoordinator + + +class LichessEntity(CoordinatorEntity[LichessCoordinator]): + """Base entity for Lichess integration.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: LichessCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + if TYPE_CHECKING: + assert coordinator.config_entry.unique_id is not None + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.unique_id)}, + entry_type=DeviceEntryType.SERVICE, + manufacturer="Lichess", + ) diff --git a/homeassistant/components/lichess/icons.json b/homeassistant/components/lichess/icons.json new file mode 100644 index 00000000000000..6ea1bda81d8d2d --- /dev/null +++ b/homeassistant/components/lichess/icons.json @@ -0,0 +1,30 @@ +{ + "entity": { + "sensor": { + "blitz_games": { + "default": "mdi:chess-pawn" + }, + "blitz_rating": { + "default": "mdi:chart-line" + }, + "bullet_games": { + "default": "mdi:chess-pawn" + }, + "bullet_rating": { + "default": "mdi:chart-line" + }, + "classical_games": { + "default": "mdi:chess-pawn" + }, + "classical_rating": { + "default": "mdi:chart-line" + }, + "rapid_games": { + "default": "mdi:chess-pawn" + }, + "rapid_rating": { + "default": "mdi:chart-line" + } + } + } +} diff --git a/homeassistant/components/lichess/manifest.json b/homeassistant/components/lichess/manifest.json new file mode 100644 index 00000000000000..a461e8b3a11136 --- /dev/null +++ b/homeassistant/components/lichess/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "lichess", + "name": "Lichess", + "codeowners": ["@aryanhasgithub"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/lichess", + "integration_type": "service", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["aiolichess==1.2.0"] +} diff --git a/homeassistant/components/lichess/quality_scale.yaml b/homeassistant/components/lichess/quality_scale.yaml new file mode 100644 index 00000000000000..c6fcad64df9e53 --- /dev/null +++ b/homeassistant/components/lichess/quality_scale.yaml @@ -0,0 +1,72 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: There are no custom actions present + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: There are no custom actions present + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: The entities do not explicitly subscribe to events + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: There are no custom actions + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: The integration does not use discovery + discovery: + status: exempt + comment: The integration does not use discovery + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/lichess/sensor.py b/homeassistant/components/lichess/sensor.py new file mode 100644 index 00000000000000..8e57d2dd59ae4a --- /dev/null +++ b/homeassistant/components/lichess/sensor.py @@ -0,0 +1,116 @@ +"""Sensor platform for Lichess integration.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from aiolichess.models import LichessStatistics + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import LichessConfigEntry +from .coordinator import LichessCoordinator +from .entity import LichessEntity + + +@dataclass(kw_only=True, frozen=True) +class LichessEntityDescription(SensorEntityDescription): + """Sensor description for Lichess player.""" + + value_fn: Callable[[LichessStatistics], int | None] + + +SENSORS: tuple[LichessEntityDescription, ...] = ( + LichessEntityDescription( + key="bullet_rating", + translation_key="bullet_rating", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda state: state.bullet_rating, + ), + LichessEntityDescription( + key="bullet_games", + translation_key="bullet_games", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda state: state.bullet_games, + ), + LichessEntityDescription( + key="blitz_rating", + translation_key="blitz_rating", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda state: state.blitz_rating, + ), + LichessEntityDescription( + key="blitz_games", + translation_key="blitz_games", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda state: state.blitz_games, + ), + LichessEntityDescription( + key="rapid_rating", + translation_key="rapid_rating", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda state: state.rapid_rating, + ), + LichessEntityDescription( + key="rapid_games", + translation_key="rapid_games", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda state: state.rapid_games, + ), + LichessEntityDescription( + key="classical_rating", + translation_key="classical_rating", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda state: state.classical_rating, + ), + LichessEntityDescription( + key="classical_games", + translation_key="classical_games", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda state: state.classical_games, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LichessConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Initialize the entries.""" + coordinator = entry.runtime_data + + async_add_entities( + LichessPlayerSensor(coordinator, description) for description in SENSORS + ) + + +class LichessPlayerSensor(LichessEntity, SensorEntity): + """Lichess sensor.""" + + entity_description: LichessEntityDescription + + def __init__( + self, + coordinator: LichessCoordinator, + description: LichessEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}.{description.key}" + + @property + def native_value(self) -> int | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/lichess/strings.json b/homeassistant/components/lichess/strings.json new file mode 100644 index 00000000000000..024d41e61d079f --- /dev/null +++ b/homeassistant/components/lichess/strings.json @@ -0,0 +1,54 @@ +{ + "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": { + "api_token": "[%key:common::config_flow::data::api_token%]" + }, + "data_description": { + "api_token": "The Lichess API token of the player." + } + } + } + }, + "entity": { + "sensor": { + "blitz_games": { + "name": "Blitz games", + "unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]" + }, + "blitz_rating": { + "name": "Blitz rating" + }, + "bullet_games": { + "name": "Bullet games", + "unit_of_measurement": "games" + }, + "bullet_rating": { + "name": "Bullet rating" + }, + "classical_games": { + "name": "Classical games", + "unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]" + }, + "classical_rating": { + "name": "Classical rating" + }, + "rapid_games": { + "name": "Rapid games", + "unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]" + }, + "rapid_rating": { + "name": "Rapid rating" + } + } + } +} diff --git a/homeassistant/components/liebherr/__init__.py b/homeassistant/components/liebherr/__init__.py index 1ce8188c04bd80..647fa90b13322a 100644 --- a/homeassistant/components/liebherr/__init__.py +++ b/homeassistant/components/liebherr/__init__.py @@ -1,8 +1,10 @@ -"""The liebherr integration.""" +"""The Liebherr integration.""" from __future__ import annotations import asyncio +from datetime import datetime +import logging from pyliebherrhomeapi import LiebherrClient from pyliebherrhomeapi.exceptions import ( @@ -13,11 +15,22 @@ from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval -from .coordinator import LiebherrConfigEntry, LiebherrCoordinator +from .const import DEVICE_SCAN_INTERVAL, DOMAIN +from .coordinator import LiebherrConfigEntry, LiebherrCoordinator, LiebherrData -PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [ + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) -> bool: @@ -37,7 +50,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) -> raise ConfigEntryNotReady(f"Failed to connect to Liebherr API: {err}") from err # Create a coordinator for each device (may be empty if no devices) - coordinators: dict[str, LiebherrCoordinator] = {} + data = LiebherrData(client=client) for device in devices: coordinator = LiebherrCoordinator( hass=hass, @@ -45,20 +58,83 @@ async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) -> client=client, device_id=device.device_id, ) - coordinators[device.device_id] = coordinator + data.coordinators[device.device_id] = coordinator await asyncio.gather( *( coordinator.async_config_entry_first_refresh() - for coordinator in coordinators.values() + for coordinator in data.coordinators.values() ) ) - # Store coordinators in runtime data - entry.runtime_data = coordinators + # Store runtime data + entry.runtime_data = data await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # Schedule periodic scan for new devices + async def _async_scan_for_new_devices(_now: datetime) -> None: + """Scan for new devices added to the account.""" + try: + devices = await client.get_devices() + except LiebherrAuthenticationError, LiebherrConnectionError: + _LOGGER.debug("Failed to scan for new devices") + return + except Exception: + _LOGGER.exception("Unexpected error scanning for new devices") + return + + # Remove stale devices no longer returned by the API + current_device_ids = {device.device_id for device in devices} + device_registry = dr.async_get(hass) + for device_entry in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + device_ids = { + identifier[1] + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + } + if device_ids - current_device_ids: + # Shut down coordinator if one exists + for device_id in device_ids: + if coordinator := data.coordinators.pop(device_id, None): + await coordinator.async_shutdown() + device_registry.async_update_device( + device_id=device_entry.id, + remove_config_entry_id=entry.entry_id, + ) + + # Add new devices + new_coordinators: list[LiebherrCoordinator] = [] + for device in devices: + if device.device_id not in data.coordinators: + coordinator = LiebherrCoordinator( + hass=hass, + config_entry=entry, + client=client, + device_id=device.device_id, + ) + await coordinator.async_refresh() + if not coordinator.last_update_success: + _LOGGER.debug("Failed to set up new device %s", device.device_id) + continue + data.coordinators[device.device_id] = coordinator + new_coordinators.append(coordinator) + + if new_coordinators: + async_dispatcher_send( + hass, + f"{DOMAIN}_new_device_{entry.entry_id}", + new_coordinators, + ) + + entry.async_on_unload( + async_track_time_interval( + hass, _async_scan_for_new_devices, DEVICE_SCAN_INTERVAL + ) + ) + return True diff --git a/homeassistant/components/liebherr/const.py b/homeassistant/components/liebherr/const.py index f02c28e46d199c..ceffd331d66a85 100644 --- a/homeassistant/components/liebherr/const.py +++ b/homeassistant/components/liebherr/const.py @@ -1,6 +1,11 @@ """Constants for the liebherr integration.""" +from datetime import timedelta from typing import Final DOMAIN: Final = "liebherr" MANUFACTURER: Final = "Liebherr" + +SCAN_INTERVAL: Final = timedelta(seconds=60) +DEVICE_SCAN_INTERVAL: Final = timedelta(minutes=5) +REFRESH_DELAY: Final = timedelta(seconds=5) diff --git a/homeassistant/components/liebherr/coordinator.py b/homeassistant/components/liebherr/coordinator.py index c840237371d47e..1364149f2c5df4 100644 --- a/homeassistant/components/liebherr/coordinator.py +++ b/homeassistant/components/liebherr/coordinator.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import timedelta +from dataclasses import dataclass, field import logging from pyliebherrhomeapi import ( @@ -18,13 +18,20 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN - -type LiebherrConfigEntry = ConfigEntry[dict[str, LiebherrCoordinator]] +from .const import DOMAIN, SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=60) + +@dataclass +class LiebherrData: + """Runtime data for the Liebherr integration.""" + + client: LiebherrClient + coordinators: dict[str, LiebherrCoordinator] = field(default_factory=dict) + + +type LiebherrConfigEntry = ConfigEntry[LiebherrData] class LiebherrCoordinator(DataUpdateCoordinator[DeviceState]): diff --git a/homeassistant/components/liebherr/diagnostics.py b/homeassistant/components/liebherr/diagnostics.py index 21e6ab7af4ceae..a86b52aac918ba 100644 --- a/homeassistant/components/liebherr/diagnostics.py +++ b/homeassistant/components/liebherr/diagnostics.py @@ -29,6 +29,6 @@ async def async_get_config_entry_diagnostics( }, "data": asdict(coordinator.data), } - for device_id, coordinator in entry.runtime_data.items() + for device_id, coordinator in entry.runtime_data.coordinators.items() }, } diff --git a/homeassistant/components/liebherr/entity.py b/homeassistant/components/liebherr/entity.py index 1e5dc7ca385725..eb343491dce982 100644 --- a/homeassistant/components/liebherr/entity.py +++ b/homeassistant/components/liebherr/entity.py @@ -2,12 +2,22 @@ from __future__ import annotations -from pyliebherrhomeapi import TemperatureControl, ZonePosition - +import asyncio +from collections.abc import Coroutine +from typing import Any + +from pyliebherrhomeapi import ( + LiebherrConnectionError, + LiebherrTimeoutError, + TemperatureControl, + ZonePosition, +) + +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, MANUFACTURER +from .const import DOMAIN, MANUFACTURER, REFRESH_DELAY from .coordinator import LiebherrCoordinator # Zone position to translation key mapping @@ -44,6 +54,22 @@ def __init__( model_id=device.device_name, ) + async def _async_send_command( + self, + command: Coroutine[Any, Any, None], + ) -> None: + """Send a command with error handling and delayed refresh.""" + try: + await command + except (LiebherrConnectionError, LiebherrTimeoutError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="communication_error", + ) from err + + await asyncio.sleep(REFRESH_DELAY.total_seconds()) + await self.coordinator.async_request_refresh() + class LiebherrZoneEntity(LiebherrEntity): """Base entity for zone-based Liebherr entities. diff --git a/homeassistant/components/liebherr/icons.json b/homeassistant/components/liebherr/icons.json index 39e9f59e50c940..0aa3f37c7e2b47 100644 --- a/homeassistant/components/liebherr/icons.json +++ b/homeassistant/components/liebherr/icons.json @@ -1,5 +1,55 @@ { "entity": { + "select": { + "bio_fresh_plus": { + "default": "mdi:leaf" + }, + "bio_fresh_plus_bottom_zone": { + "default": "mdi:leaf" + }, + "bio_fresh_plus_middle_zone": { + "default": "mdi:leaf" + }, + "bio_fresh_plus_top_zone": { + "default": "mdi:leaf" + }, + "hydro_breeze": { + "default": "mdi:weather-windy" + }, + "hydro_breeze_bottom_zone": { + "default": "mdi:weather-windy" + }, + "hydro_breeze_middle_zone": { + "default": "mdi:weather-windy" + }, + "hydro_breeze_top_zone": { + "default": "mdi:weather-windy" + }, + "ice_maker": { + "default": "mdi:cube-outline", + "state": { + "off": "mdi:cube-outline-off" + } + }, + "ice_maker_bottom_zone": { + "default": "mdi:cube-outline", + "state": { + "off": "mdi:cube-outline-off" + } + }, + "ice_maker_middle_zone": { + "default": "mdi:cube-outline", + "state": { + "off": "mdi:cube-outline-off" + } + }, + "ice_maker_top_zone": { + "default": "mdi:cube-outline", + "state": { + "off": "mdi:cube-outline-off" + } + } + }, "switch": { "night_mode": { "default": "mdi:sleep", @@ -13,49 +63,49 @@ "off": "mdi:glass-cocktail-off" } }, - "supercool": { + "super_cool": { "default": "mdi:snowflake", "state": { "off": "mdi:snowflake-off" } }, - "supercool_bottom_zone": { + "super_cool_bottom_zone": { "default": "mdi:snowflake", "state": { "off": "mdi:snowflake-off" } }, - "supercool_middle_zone": { + "super_cool_middle_zone": { "default": "mdi:snowflake", "state": { "off": "mdi:snowflake-off" } }, - "supercool_top_zone": { + "super_cool_top_zone": { "default": "mdi:snowflake", "state": { "off": "mdi:snowflake-off" } }, - "superfrost": { + "super_frost": { "default": "mdi:snowflake-alert", "state": { "off": "mdi:snowflake-off" } }, - "superfrost_bottom_zone": { + "super_frost_bottom_zone": { "default": "mdi:snowflake-alert", "state": { "off": "mdi:snowflake-off" } }, - "superfrost_middle_zone": { + "super_frost_middle_zone": { "default": "mdi:snowflake-alert", "state": { "off": "mdi:snowflake-off" } }, - "superfrost_top_zone": { + "super_frost_top_zone": { "default": "mdi:snowflake-alert", "state": { "off": "mdi:snowflake-off" diff --git a/homeassistant/components/liebherr/manifest.json b/homeassistant/components/liebherr/manifest.json index 86a664362bfded..08c69a441a12c6 100644 --- a/homeassistant/components/liebherr/manifest.json +++ b/homeassistant/components/liebherr/manifest.json @@ -7,8 +7,8 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyliebherrhomeapi"], - "quality_scale": "silver", - "requirements": ["pyliebherrhomeapi==0.2.1"], + "quality_scale": "gold", + "requirements": ["pyliebherrhomeapi==0.4.0"], "zeroconf": [ { "name": "liebherr*", diff --git a/homeassistant/components/liebherr/number.py b/homeassistant/components/liebherr/number.py index 0841d29174a273..46a44e23d086da 100644 --- a/homeassistant/components/liebherr/number.py +++ b/homeassistant/components/liebherr/number.py @@ -4,13 +4,9 @@ from collections.abc import Callable from dataclasses import dataclass +from typing import TYPE_CHECKING -from pyliebherrhomeapi import ( - LiebherrConnectionError, - LiebherrTimeoutError, - TemperatureControl, - TemperatureUnit, -) +from pyliebherrhomeapi import TemperatureControl, TemperatureUnit from homeassistant.components.number import ( DEFAULT_MAX_VALUE, @@ -20,8 +16,8 @@ NumberEntityDescription, ) from homeassistant.const import UnitOfTemperature -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +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 @@ -59,22 +55,41 @@ class LiebherrNumberEntityDescription(NumberEntityDescription): ) +def _create_number_entities( + coordinators: list[LiebherrCoordinator], +) -> list[LiebherrNumber]: + """Create number entities for the given coordinators.""" + return [ + LiebherrNumber( + coordinator=coordinator, + zone_id=temp_control.zone_id, + description=description, + ) + for coordinator in coordinators + for temp_control in coordinator.data.get_temperature_controls().values() + for description in NUMBER_TYPES + ] + + async def async_setup_entry( hass: HomeAssistant, entry: LiebherrConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Liebherr number entities.""" - coordinators = entry.runtime_data async_add_entities( - LiebherrNumber( - coordinator=coordinator, - zone_id=temp_control.zone_id, - description=description, + _create_number_entities(list(entry.runtime_data.coordinators.values())) + ) + + @callback + def _async_new_device(coordinators: list[LiebherrCoordinator]) -> None: + """Add number entities for new devices.""" + async_add_entities(_create_number_entities(coordinators)) + + entry.async_on_unload( + async_dispatcher_connect( + hass, f"{DOMAIN}_new_device_{entry.entry_id}", _async_new_device ) - for coordinator in coordinators.values() - for temp_control in coordinator.data.get_temperature_controls().values() - for description in NUMBER_TYPES ) @@ -109,10 +124,9 @@ def native_unit_of_measurement(self) -> str | None: @property def native_value(self) -> float | None: """Return the current value.""" - # temperature_control is guaranteed to exist when entity is available - return self.entity_description.value_fn( - self.temperature_control # type: ignore[arg-type] - ) + if TYPE_CHECKING: + assert self.temperature_control is not None + return self.entity_description.value_fn(self.temperature_control) @property def native_min_value(self) -> float: @@ -139,27 +153,21 @@ def available(self) -> bool: async def async_set_native_value(self, value: float) -> None: """Set new value.""" - # temperature_control is guaranteed to exist when entity is available + if TYPE_CHECKING: + assert self.temperature_control is not None temp_control = self.temperature_control unit = ( TemperatureUnit.FAHRENHEIT - if temp_control.unit == TemperatureUnit.FAHRENHEIT # type: ignore[union-attr] + if temp_control.unit == TemperatureUnit.FAHRENHEIT else TemperatureUnit.CELSIUS ) - try: - await self.coordinator.client.set_temperature( + await self._async_send_command( + self.coordinator.client.set_temperature( device_id=self.coordinator.device_id, zone_id=self._zone_id, target=int(value), unit=unit, - ) - except (LiebherrConnectionError, LiebherrTimeoutError) as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="communication_error", - translation_placeholders={"error": str(err)}, - ) from err - - await self.coordinator.async_request_refresh() + ), + ) diff --git a/homeassistant/components/liebherr/quality_scale.yaml b/homeassistant/components/liebherr/quality_scale.yaml index 1d24e92c1dfd58..712bedd1c2a109 100644 --- a/homeassistant/components/liebherr/quality_scale.yaml +++ b/homeassistant/components/liebherr/quality_scale.yaml @@ -47,13 +47,13 @@ rules: comment: Cloud API does not require updating entry data from network discovery. discovery: done docs-data-update: done - docs-examples: todo + docs-examples: done docs-known-limitations: done docs-supported-devices: done docs-supported-functions: done docs-troubleshooting: done docs-use-cases: done - dynamic-devices: todo + dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: @@ -68,7 +68,7 @@ rules: repair-issues: status: exempt comment: No repair issues to implement at this time. - stale-devices: todo + stale-devices: done # Platinum async-dependency: done diff --git a/homeassistant/components/liebherr/select.py b/homeassistant/components/liebherr/select.py new file mode 100644 index 00000000000000..c637eb01a8fd9f --- /dev/null +++ b/homeassistant/components/liebherr/select.py @@ -0,0 +1,238 @@ +"""Select platform for Liebherr integration.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from enum import StrEnum +from typing import TYPE_CHECKING, Any + +from pyliebherrhomeapi import ( + BioFreshPlusControl, + BioFreshPlusMode, + HydroBreezeControl, + HydroBreezeMode, + IceMakerControl, + IceMakerMode, + ZonePosition, +) + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +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 +from .coordinator import LiebherrConfigEntry, LiebherrCoordinator +from .entity import ZONE_POSITION_MAP, LiebherrEntity + +PARALLEL_UPDATES = 1 + +type SelectControl = IceMakerControl | HydroBreezeControl | BioFreshPlusControl + + +@dataclass(frozen=True, kw_only=True) +class LiebherrSelectEntityDescription(SelectEntityDescription): + """Describes a Liebherr select entity.""" + + control_type: type[SelectControl] + mode_enum: type[StrEnum] + current_mode_fn: Callable[[SelectControl], StrEnum | str | None] + options_fn: Callable[[SelectControl], list[str]] + set_fn: Callable[[LiebherrCoordinator, int, StrEnum], Coroutine[Any, Any, None]] + + +def _ice_maker_options(control: SelectControl) -> list[str]: + """Return available ice maker options.""" + if TYPE_CHECKING: + assert isinstance(control, IceMakerControl) + options = [IceMakerMode.OFF.value, IceMakerMode.ON.value] + if control.has_max_ice: + options.append(IceMakerMode.MAX_ICE.value) + return options + + +def _hydro_breeze_options(control: SelectControl) -> list[str]: + """Return available HydroBreeze options.""" + return [mode.value for mode in HydroBreezeMode] + + +def _bio_fresh_plus_options(control: SelectControl) -> list[str]: + """Return available BioFresh-Plus options.""" + if TYPE_CHECKING: + assert isinstance(control, BioFreshPlusControl) + return [ + mode.value + for mode in control.supported_modes + if isinstance(mode, BioFreshPlusMode) + ] + + +SELECT_TYPES: list[LiebherrSelectEntityDescription] = [ + LiebherrSelectEntityDescription( + key="ice_maker", + translation_key="ice_maker", + control_type=IceMakerControl, + mode_enum=IceMakerMode, + current_mode_fn=lambda c: c.ice_maker_mode, # type: ignore[union-attr] + options_fn=_ice_maker_options, + set_fn=lambda coordinator, zone_id, mode: coordinator.client.set_ice_maker( + device_id=coordinator.device_id, + zone_id=zone_id, + mode=mode, # type: ignore[arg-type] + ), + ), + LiebherrSelectEntityDescription( + key="hydro_breeze", + translation_key="hydro_breeze", + control_type=HydroBreezeControl, + mode_enum=HydroBreezeMode, + current_mode_fn=lambda c: c.current_mode, # type: ignore[union-attr] + options_fn=_hydro_breeze_options, + set_fn=lambda coordinator, zone_id, mode: coordinator.client.set_hydro_breeze( + device_id=coordinator.device_id, + zone_id=zone_id, + mode=mode, # type: ignore[arg-type] + ), + ), + LiebherrSelectEntityDescription( + key="bio_fresh_plus", + translation_key="bio_fresh_plus", + control_type=BioFreshPlusControl, + mode_enum=BioFreshPlusMode, + current_mode_fn=lambda c: c.current_mode, # type: ignore[union-attr] + options_fn=_bio_fresh_plus_options, + set_fn=lambda coordinator, zone_id, mode: coordinator.client.set_bio_fresh_plus( + device_id=coordinator.device_id, + zone_id=zone_id, + mode=mode, # type: ignore[arg-type] + ), + ), +] + + +def _create_select_entities( + coordinators: list[LiebherrCoordinator], +) -> list[LiebherrSelectEntity]: + """Create select entities for the given coordinators.""" + entities: list[LiebherrSelectEntity] = [] + + for coordinator in coordinators: + has_multiple_zones = len(coordinator.data.get_temperature_controls()) > 1 + + for control in coordinator.data.controls: + for description in SELECT_TYPES: + if isinstance(control, description.control_type): + if TYPE_CHECKING: + assert isinstance( + control, + IceMakerControl | HydroBreezeControl | BioFreshPlusControl, + ) + entities.append( + LiebherrSelectEntity( + coordinator=coordinator, + description=description, + zone_id=control.zone_id, + has_multiple_zones=has_multiple_zones, + ) + ) + + return entities + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LiebherrConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Liebherr select entities.""" + async_add_entities( + _create_select_entities(list(entry.runtime_data.coordinators.values())) + ) + + @callback + def _async_new_device(coordinators: list[LiebherrCoordinator]) -> None: + """Add select entities for new devices.""" + async_add_entities(_create_select_entities(coordinators)) + + entry.async_on_unload( + async_dispatcher_connect( + hass, f"{DOMAIN}_new_device_{entry.entry_id}", _async_new_device + ) + ) + + +class LiebherrSelectEntity(LiebherrEntity, SelectEntity): + """Representation of a Liebherr select entity.""" + + entity_description: LiebherrSelectEntityDescription + + def __init__( + self, + coordinator: LiebherrCoordinator, + description: LiebherrSelectEntityDescription, + zone_id: int, + has_multiple_zones: bool, + ) -> None: + """Initialize the select entity.""" + super().__init__(coordinator) + self.entity_description = description + self._zone_id = zone_id + self._attr_unique_id = f"{coordinator.device_id}_{description.key}_{zone_id}" + + # Set options from the control + control = self._select_control + if control is not None: + self._attr_options = description.options_fn(control) + + # Add zone suffix only for multi-zone devices + if has_multiple_zones: + temp_controls = coordinator.data.get_temperature_controls() + if ( + (tc := temp_controls.get(zone_id)) + and isinstance(tc.zone_position, ZonePosition) + and (zone_key := ZONE_POSITION_MAP.get(tc.zone_position)) + ): + self._attr_translation_key = f"{description.translation_key}_{zone_key}" + + @property + def _select_control(self) -> SelectControl | None: + """Get the select control for this entity.""" + for control in self.coordinator.data.controls: + if not isinstance( + control, + IceMakerControl | HydroBreezeControl | BioFreshPlusControl, + ): + continue + if ( + isinstance(control, self.entity_description.control_type) + and control.zone_id == self._zone_id + ): + return control + return None + + @property + def current_option(self) -> str | None: + """Return the current selected option.""" + control = self._select_control + if TYPE_CHECKING: + assert isinstance( + control, + IceMakerControl | HydroBreezeControl | BioFreshPlusControl, + ) + mode = self.entity_description.current_mode_fn(control) + if isinstance(mode, StrEnum): + return mode.value + return None + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self._select_control is not None + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + mode = self.entity_description.mode_enum(option) + await self._async_send_command( + self.entity_description.set_fn(self.coordinator, self._zone_id, mode), + ) diff --git a/homeassistant/components/liebherr/sensor.py b/homeassistant/components/liebherr/sensor.py index aeffe616414ff1..1f4fb09dc49339 100644 --- a/homeassistant/components/liebherr/sensor.py +++ b/homeassistant/components/liebherr/sensor.py @@ -14,10 +14,12 @@ SensorStateClass, ) from homeassistant.const import UnitOfTemperature -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType +from .const import DOMAIN from .coordinator import LiebherrConfigEntry, LiebherrCoordinator from .entity import LiebherrZoneEntity @@ -48,22 +50,41 @@ class LiebherrSensorEntityDescription(SensorEntityDescription): ) +def _create_sensor_entities( + coordinators: list[LiebherrCoordinator], +) -> list[LiebherrSensor]: + """Create sensor entities for the given coordinators.""" + return [ + LiebherrSensor( + coordinator=coordinator, + zone_id=temp_control.zone_id, + description=description, + ) + for coordinator in coordinators + for temp_control in coordinator.data.get_temperature_controls().values() + for description in SENSOR_TYPES + ] + + async def async_setup_entry( hass: HomeAssistant, entry: LiebherrConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Liebherr sensor entities.""" - coordinators = entry.runtime_data async_add_entities( - LiebherrSensor( - coordinator=coordinator, - zone_id=temp_control.zone_id, - description=description, + _create_sensor_entities(list(entry.runtime_data.coordinators.values())) + ) + + @callback + def _async_new_device(coordinators: list[LiebherrCoordinator]) -> None: + """Add sensor entities for new devices.""" + async_add_entities(_create_sensor_entities(coordinators)) + + entry.async_on_unload( + async_dispatcher_connect( + hass, f"{DOMAIN}_new_device_{entry.entry_id}", _async_new_device ) - for coordinator in coordinators.values() - for temp_control in coordinator.data.get_temperature_controls().values() - for description in SENSOR_TYPES ) diff --git a/homeassistant/components/liebherr/strings.json b/homeassistant/components/liebherr/strings.json index 3549760f577f02..9ddcfab2dfcabc 100644 --- a/homeassistant/components/liebherr/strings.json +++ b/homeassistant/components/liebherr/strings.json @@ -47,6 +47,112 @@ "name": "Top zone setpoint" } }, + "select": { + "bio_fresh_plus": { + "name": "BioFresh-Plus", + "state": { + "minus_two_minus_two": "-2°C | -2°C", + "minus_two_zero": "-2°C | 0°C", + "zero_minus_two": "0°C | -2°C", + "zero_zero": "0°C | 0°C" + } + }, + "bio_fresh_plus_bottom_zone": { + "name": "Bottom zone BioFresh-Plus", + "state": { + "minus_two_minus_two": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::minus_two_minus_two%]", + "minus_two_zero": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::minus_two_zero%]", + "zero_minus_two": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::zero_minus_two%]", + "zero_zero": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::zero_zero%]" + } + }, + "bio_fresh_plus_middle_zone": { + "name": "Middle zone BioFresh-Plus", + "state": { + "minus_two_minus_two": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::minus_two_minus_two%]", + "minus_two_zero": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::minus_two_zero%]", + "zero_minus_two": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::zero_minus_two%]", + "zero_zero": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::zero_zero%]" + } + }, + "bio_fresh_plus_top_zone": { + "name": "Top zone BioFresh-Plus", + "state": { + "minus_two_minus_two": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::minus_two_minus_two%]", + "minus_two_zero": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::minus_two_zero%]", + "zero_minus_two": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::zero_minus_two%]", + "zero_zero": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::zero_zero%]" + } + }, + "hydro_breeze": { + "name": "HydroBreeze", + "state": { + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" + } + }, + "hydro_breeze_bottom_zone": { + "name": "Bottom zone HydroBreeze", + "state": { + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" + } + }, + "hydro_breeze_middle_zone": { + "name": "Middle zone HydroBreeze", + "state": { + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" + } + }, + "hydro_breeze_top_zone": { + "name": "Top zone HydroBreeze", + "state": { + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" + } + }, + "ice_maker": { + "name": "IceMaker", + "state": { + "max_ice": "MaxIce", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + }, + "ice_maker_bottom_zone": { + "name": "Bottom zone IceMaker", + "state": { + "max_ice": "[%key:component::liebherr::entity::select::ice_maker::state::max_ice%]", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + }, + "ice_maker_middle_zone": { + "name": "Middle zone IceMaker", + "state": { + "max_ice": "[%key:component::liebherr::entity::select::ice_maker::state::max_ice%]", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + }, + "ice_maker_top_zone": { + "name": "Top zone IceMaker", + "state": { + "max_ice": "[%key:component::liebherr::entity::select::ice_maker::state::max_ice%]", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + } + }, "sensor": { "bottom_zone": { "name": "Bottom zone" @@ -60,40 +166,40 @@ }, "switch": { "night_mode": { - "name": "Night mode" + "name": "NightMode" }, "party_mode": { - "name": "Party mode" + "name": "PartyMode" }, - "supercool": { + "super_cool": { "name": "SuperCool" }, - "supercool_bottom_zone": { + "super_cool_bottom_zone": { "name": "Bottom zone SuperCool" }, - "supercool_middle_zone": { + "super_cool_middle_zone": { "name": "Middle zone SuperCool" }, - "supercool_top_zone": { + "super_cool_top_zone": { "name": "Top zone SuperCool" }, - "superfrost": { + "super_frost": { "name": "SuperFrost" }, - "superfrost_bottom_zone": { + "super_frost_bottom_zone": { "name": "Bottom zone SuperFrost" }, - "superfrost_middle_zone": { + "super_frost_middle_zone": { "name": "Middle zone SuperFrost" }, - "superfrost_top_zone": { + "super_frost_top_zone": { "name": "Top zone SuperFrost" } } }, "exceptions": { "communication_error": { - "message": "An error occurred while communicating with the device: {error}" + "message": "An error occurred while communicating with the device" } } } diff --git a/homeassistant/components/liebherr/switch.py b/homeassistant/components/liebherr/switch.py index db07860d677fb1..aba8da3f418f26 100644 --- a/homeassistant/components/liebherr/switch.py +++ b/homeassistant/components/liebherr/switch.py @@ -2,21 +2,21 @@ from __future__ import annotations -import asyncio from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import TYPE_CHECKING, Any -from pyliebherrhomeapi import ( - LiebherrConnectionError, - LiebherrTimeoutError, - ToggleControl, - ZonePosition, +from pyliebherrhomeapi import ToggleControl, ZonePosition +from pyliebherrhomeapi.const import ( + CONTROL_NIGHT_MODE, + CONTROL_PARTY_MODE, + CONTROL_SUPER_COOL, + CONTROL_SUPER_FROST, ) from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +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 @@ -24,13 +24,6 @@ from .entity import ZONE_POSITION_MAP, LiebherrEntity PARALLEL_UPDATES = 1 -REFRESH_DELAY = 5 - -# Control names from the API -CONTROL_SUPERCOOL = "supercool" -CONTROL_SUPERFROST = "superfrost" -CONTROL_PARTY_MODE = "partymode" -CONTROL_NIGHT_MODE = "nightmode" @dataclass(frozen=True, kw_only=True) @@ -55,21 +48,21 @@ class LiebherrDeviceSwitchEntityDescription(LiebherrSwitchEntityDescription): ZONE_SWITCH_TYPES: dict[str, LiebherrZoneSwitchEntityDescription] = { - CONTROL_SUPERCOOL: LiebherrZoneSwitchEntityDescription( - key="supercool", - translation_key="supercool", - control_name=CONTROL_SUPERCOOL, - set_fn=lambda coordinator, zone_id, value: coordinator.client.set_supercool( + CONTROL_SUPER_COOL: LiebherrZoneSwitchEntityDescription( + key="super_cool", + translation_key="super_cool", + control_name=CONTROL_SUPER_COOL, + set_fn=lambda coordinator, zone_id, value: coordinator.client.set_super_cool( device_id=coordinator.device_id, zone_id=zone_id, value=value, ), ), - CONTROL_SUPERFROST: LiebherrZoneSwitchEntityDescription( - key="superfrost", - translation_key="superfrost", - control_name=CONTROL_SUPERFROST, - set_fn=lambda coordinator, zone_id, value: coordinator.client.set_superfrost( + CONTROL_SUPER_FROST: LiebherrZoneSwitchEntityDescription( + key="super_frost", + translation_key="super_frost", + control_name=CONTROL_SUPER_FROST, + set_fn=lambda coordinator, zone_id, value: coordinator.client.set_super_frost( device_id=coordinator.device_id, zone_id=zone_id, value=value, @@ -99,15 +92,13 @@ class LiebherrDeviceSwitchEntityDescription(LiebherrSwitchEntityDescription): } -async def async_setup_entry( - hass: HomeAssistant, - entry: LiebherrConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up Liebherr switch entities.""" +def _create_switch_entities( + coordinators: list[LiebherrCoordinator], +) -> list[LiebherrDeviceSwitch | LiebherrZoneSwitch]: + """Create switch entities for the given coordinators.""" entities: list[LiebherrDeviceSwitch | LiebherrZoneSwitch] = [] - for coordinator in entry.runtime_data.values(): + for coordinator in coordinators: has_multiple_zones = len(coordinator.data.get_temperature_controls()) > 1 for control in coordinator.data.controls: @@ -127,7 +118,7 @@ async def async_setup_entry( ) ) - # Device-wide switches (Party Mode, Night Mode) + # Device-wide switches (PartyMode, NightMode) elif device_desc := DEVICE_SWITCH_TYPES.get(control.name): entities.append( LiebherrDeviceSwitch( @@ -136,7 +127,29 @@ async def async_setup_entry( ) ) - async_add_entities(entities) + return entities + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LiebherrConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Liebherr switch entities.""" + async_add_entities( + _create_switch_entities(list(entry.runtime_data.coordinators.values())) + ) + + @callback + def _async_new_device(coordinators: list[LiebherrCoordinator]) -> None: + """Add switch entities for new devices.""" + async_add_entities(_create_switch_entities(coordinators)) + + entry.async_on_unload( + async_dispatcher_connect( + hass, f"{DOMAIN}_new_device_{entry.entry_id}", _async_new_device + ) + ) class LiebherrDeviceSwitch(LiebherrEntity, SwitchEntity): @@ -144,7 +157,6 @@ class LiebherrDeviceSwitch(LiebherrEntity, SwitchEntity): entity_description: LiebherrSwitchEntityDescription _zone_id: int | None = None - _optimistic_state: bool | None = None def __init__( self, @@ -171,17 +183,10 @@ def _toggle_control(self) -> ToggleControl | None: @property def is_on(self) -> bool | None: """Return true if the switch is on.""" - if self._optimistic_state is not None: - return self._optimistic_state if TYPE_CHECKING: assert self._toggle_control is not None return self._toggle_control.value - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self._optimistic_state = None - super()._handle_coordinator_update() - @property def available(self) -> bool: """Return if entity is available.""" @@ -205,21 +210,7 @@ async def _async_call_set_fn(self, value: bool) -> None: async def _async_set_value(self, value: bool) -> None: """Set the switch value.""" - try: - await self._async_call_set_fn(value) - except (LiebherrConnectionError, LiebherrTimeoutError) as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="communication_error", - translation_placeholders={"error": str(err)}, - ) from err - - # Track expected state locally to avoid mutating shared coordinator data - self._optimistic_state = value - self.async_write_ha_state() - - await asyncio.sleep(REFRESH_DELAY) - await self.coordinator.async_request_refresh() + await self._async_send_command(self._async_call_set_fn(value)) class LiebherrZoneSwitch(LiebherrDeviceSwitch): diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 746c88037c49f7..de1f9841a50785 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -7,7 +7,7 @@ import dataclasses import logging import os -from typing import TYPE_CHECKING, Any, Self, cast, final +from typing import TYPE_CHECKING, Any, Self, cast, final, override from propcache.api import cached_property import voluptuous as vol @@ -272,6 +272,18 @@ def filter_turn_off_params( return {k: v for k, v in params.items() if k in (ATTR_TRANSITION, ATTR_FLASH)} +def process_turn_off_params( + hass: HomeAssistant, light: LightEntity, params: dict[str, Any] +) -> dict[str, Any]: + """Process light turn off params.""" + params = dict(params) + + if ATTR_TRANSITION not in params: + hass.data[DATA_PROFILES].apply_default(light.entity_id, True, params) + + return params + + def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[str, Any]: """Filter out params not supported by the light.""" supported_features = light.supported_features @@ -306,7 +318,171 @@ def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[st return params -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901 +def process_turn_on_params( # noqa: C901 + hass: HomeAssistant, light: LightEntity, params: dict[str, Any] +) -> dict[str, Any]: + """Process light turn on params.""" + params = dict(params) + + # Only process params once we processed brightness step + if params and ( + ATTR_BRIGHTNESS_STEP in params or ATTR_BRIGHTNESS_STEP_PCT in params + ): + brightness = light.brightness if light.is_on and light.brightness else 0 + + if ATTR_BRIGHTNESS_STEP in params: + brightness += params.pop(ATTR_BRIGHTNESS_STEP) + + else: + brightness_pct = round(brightness / 255 * 100) + brightness = round( + (brightness_pct + params.pop(ATTR_BRIGHTNESS_STEP_PCT)) / 100 * 255 + ) + + params[ATTR_BRIGHTNESS] = max(0, min(255, brightness)) + + preprocess_turn_on_alternatives(hass, params) + + if (not params or not light.is_on) or (params and ATTR_TRANSITION not in params): + hass.data[DATA_PROFILES].apply_default(light.entity_id, light.is_on, params) + + supported_color_modes = light._light_internal_supported_color_modes # noqa: SLF001 + + # If a color temperature is specified, emulate it if not supported by the light + if ATTR_COLOR_TEMP_KELVIN in params: + if ( + ColorMode.COLOR_TEMP not in supported_color_modes + and ColorMode.RGBWW in supported_color_modes + ): + color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN) + brightness = cast(int, params.get(ATTR_BRIGHTNESS, light.brightness)) + params[ATTR_RGBWW_COLOR] = color_util.color_temperature_to_rgbww( + color_temp, + brightness, + light.min_color_temp_kelvin, + light.max_color_temp_kelvin, + ) + elif ColorMode.COLOR_TEMP not in supported_color_modes: + color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN) + if color_supported(supported_color_modes): + params[ATTR_HS_COLOR] = color_util.color_temperature_to_hs(color_temp) + + # If a color is specified, convert to the color space supported by the light + rgb_color: tuple[int, int, int] | None + rgbww_color: tuple[int, int, int, int, int] | None + if ATTR_HS_COLOR in params and ColorMode.HS not in supported_color_modes: + hs_color = params.pop(ATTR_HS_COLOR) + if ColorMode.RGB in supported_color_modes: + params[ATTR_RGB_COLOR] = color_util.color_hs_to_RGB(*hs_color) + elif ColorMode.RGBW in supported_color_modes: + rgb_color = color_util.color_hs_to_RGB(*hs_color) + params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color) + elif ColorMode.RGBWW in supported_color_modes: + rgb_color = color_util.color_hs_to_RGB(*hs_color) + params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww( + *rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin + ) + elif ColorMode.XY in supported_color_modes: + params[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_hs_to_xy(*hs_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + elif ATTR_RGB_COLOR in params and ColorMode.RGB not in supported_color_modes: + rgb_color = params.pop(ATTR_RGB_COLOR) + assert rgb_color is not None + if TYPE_CHECKING: + rgb_color = cast(tuple[int, int, int], rgb_color) + if ColorMode.RGBW in supported_color_modes: + params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color) + elif ColorMode.RGBWW in supported_color_modes: + params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww( + *rgb_color, + light.min_color_temp_kelvin, + light.max_color_temp_kelvin, + ) + elif ColorMode.HS in supported_color_modes: + params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) + elif ColorMode.XY in supported_color_modes: + params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_RGB_to_xy(*rgb_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + elif ATTR_XY_COLOR in params and ColorMode.XY not in supported_color_modes: + xy_color = params.pop(ATTR_XY_COLOR) + if ColorMode.HS in supported_color_modes: + params[ATTR_HS_COLOR] = color_util.color_xy_to_hs(*xy_color) + elif ColorMode.RGB in supported_color_modes: + params[ATTR_RGB_COLOR] = color_util.color_xy_to_RGB(*xy_color) + elif ColorMode.RGBW in supported_color_modes: + rgb_color = color_util.color_xy_to_RGB(*xy_color) + params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color) + elif ColorMode.RGBWW in supported_color_modes: + rgb_color = color_util.color_xy_to_RGB(*xy_color) + params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww( + *rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin + ) + elif ColorMode.COLOR_TEMP in supported_color_modes: + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + elif ATTR_RGBW_COLOR in params and ColorMode.RGBW not in supported_color_modes: + rgbw_color = params.pop(ATTR_RGBW_COLOR) + rgb_color = color_util.color_rgbw_to_rgb(*rgbw_color) + if ColorMode.RGB in supported_color_modes: + params[ATTR_RGB_COLOR] = rgb_color + elif ColorMode.RGBWW in supported_color_modes: + params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww( + *rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin + ) + elif ColorMode.HS in supported_color_modes: + params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) + elif ColorMode.XY in supported_color_modes: + params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_RGB_to_xy(*rgb_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + elif ATTR_RGBWW_COLOR in params and ColorMode.RGBWW not in supported_color_modes: + rgbww_color = params.pop(ATTR_RGBWW_COLOR) + assert rgbww_color is not None + if TYPE_CHECKING: + rgbww_color = cast(tuple[int, int, int, int, int], rgbww_color) + rgb_color = color_util.color_rgbww_to_rgb( + *rgbww_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin + ) + if ColorMode.RGB in supported_color_modes: + params[ATTR_RGB_COLOR] = rgb_color + elif ColorMode.RGBW in supported_color_modes: + params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color) + elif ColorMode.HS in supported_color_modes: + params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) + elif ColorMode.XY in supported_color_modes: + params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_RGB_to_xy(*rgb_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + + # If white is set to True, set it to the light's brightness + # Add a warning in Home Assistant Core 2024.3 if the brightness is set to an + # integer. + if params.get(ATTR_WHITE) is True: + params[ATTR_WHITE] = light.brightness + + # If both white and brightness are specified, override white + if ATTR_WHITE in params and ColorMode.WHITE in supported_color_modes: + params[ATTR_WHITE] = params.pop(ATTR_BRIGHTNESS, params[ATTR_WHITE]) + + return params + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Expose light control via state machine and services.""" component = hass.data[DATA_COMPONENT] = EntityComponent[LightEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL @@ -330,177 +506,15 @@ def preprocess_data(data: dict[str, Any]) -> VolDictType: base["params"] = data return base - async def async_handle_light_on_service( # noqa: C901 + async def async_handle_light_on_service( light: LightEntity, call: ServiceCall ) -> None: """Handle turning a light on. If brightness is set to 0, this service will turn the light off. """ - params: dict[str, Any] = dict(call.data["params"]) + params = process_turn_on_params(hass, light, call.data["params"]) - # Only process params once we processed brightness step - if params and ( - ATTR_BRIGHTNESS_STEP in params or ATTR_BRIGHTNESS_STEP_PCT in params - ): - brightness = light.brightness if light.is_on and light.brightness else 0 - - if ATTR_BRIGHTNESS_STEP in params: - brightness += params.pop(ATTR_BRIGHTNESS_STEP) - - else: - brightness_pct = round(brightness / 255 * 100) - brightness = round( - (brightness_pct + params.pop(ATTR_BRIGHTNESS_STEP_PCT)) / 100 * 255 - ) - - params[ATTR_BRIGHTNESS] = max(0, min(255, brightness)) - - preprocess_turn_on_alternatives(hass, params) - - if (not params or not light.is_on) or ( - params and ATTR_TRANSITION not in params - ): - profiles.apply_default(light.entity_id, light.is_on, params) - - supported_color_modes = light._light_internal_supported_color_modes # noqa: SLF001 - - # If a color temperature is specified, emulate it if not supported by the light - if ATTR_COLOR_TEMP_KELVIN in params: - if ( - ColorMode.COLOR_TEMP not in supported_color_modes - and ColorMode.RGBWW in supported_color_modes - ): - color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN) - brightness = cast(int, params.get(ATTR_BRIGHTNESS, light.brightness)) - params[ATTR_RGBWW_COLOR] = color_util.color_temperature_to_rgbww( - color_temp, - brightness, - light.min_color_temp_kelvin, - light.max_color_temp_kelvin, - ) - elif ColorMode.COLOR_TEMP not in supported_color_modes: - color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN) - if color_supported(supported_color_modes): - params[ATTR_HS_COLOR] = color_util.color_temperature_to_hs( - color_temp - ) - - # If a color is specified, convert to the color space supported by the light - rgb_color: tuple[int, int, int] | None - rgbww_color: tuple[int, int, int, int, int] | None - if ATTR_HS_COLOR in params and ColorMode.HS not in supported_color_modes: - hs_color = params.pop(ATTR_HS_COLOR) - if ColorMode.RGB in supported_color_modes: - params[ATTR_RGB_COLOR] = color_util.color_hs_to_RGB(*hs_color) - elif ColorMode.RGBW in supported_color_modes: - rgb_color = color_util.color_hs_to_RGB(*hs_color) - params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color) - elif ColorMode.RGBWW in supported_color_modes: - rgb_color = color_util.color_hs_to_RGB(*hs_color) - params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww( - *rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin - ) - elif ColorMode.XY in supported_color_modes: - params[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color) - elif ColorMode.COLOR_TEMP in supported_color_modes: - xy_color = color_util.color_hs_to_xy(*hs_color) - params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( - *xy_color - ) - elif ATTR_RGB_COLOR in params and ColorMode.RGB not in supported_color_modes: - rgb_color = params.pop(ATTR_RGB_COLOR) - assert rgb_color is not None - if TYPE_CHECKING: - rgb_color = cast(tuple[int, int, int], rgb_color) - if ColorMode.RGBW in supported_color_modes: - params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color) - elif ColorMode.RGBWW in supported_color_modes: - params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww( - *rgb_color, - light.min_color_temp_kelvin, - light.max_color_temp_kelvin, - ) - elif ColorMode.HS in supported_color_modes: - params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) - elif ColorMode.XY in supported_color_modes: - params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) - elif ColorMode.COLOR_TEMP in supported_color_modes: - xy_color = color_util.color_RGB_to_xy(*rgb_color) - params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( - *xy_color - ) - elif ATTR_XY_COLOR in params and ColorMode.XY not in supported_color_modes: - xy_color = params.pop(ATTR_XY_COLOR) - if ColorMode.HS in supported_color_modes: - params[ATTR_HS_COLOR] = color_util.color_xy_to_hs(*xy_color) - elif ColorMode.RGB in supported_color_modes: - params[ATTR_RGB_COLOR] = color_util.color_xy_to_RGB(*xy_color) - elif ColorMode.RGBW in supported_color_modes: - rgb_color = color_util.color_xy_to_RGB(*xy_color) - params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color) - elif ColorMode.RGBWW in supported_color_modes: - rgb_color = color_util.color_xy_to_RGB(*xy_color) - params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww( - *rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin - ) - elif ColorMode.COLOR_TEMP in supported_color_modes: - params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( - *xy_color - ) - elif ATTR_RGBW_COLOR in params and ColorMode.RGBW not in supported_color_modes: - rgbw_color = params.pop(ATTR_RGBW_COLOR) - rgb_color = color_util.color_rgbw_to_rgb(*rgbw_color) - if ColorMode.RGB in supported_color_modes: - params[ATTR_RGB_COLOR] = rgb_color - elif ColorMode.RGBWW in supported_color_modes: - params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww( - *rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin - ) - elif ColorMode.HS in supported_color_modes: - params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) - elif ColorMode.XY in supported_color_modes: - params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) - elif ColorMode.COLOR_TEMP in supported_color_modes: - xy_color = color_util.color_RGB_to_xy(*rgb_color) - params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( - *xy_color - ) - elif ( - ATTR_RGBWW_COLOR in params and ColorMode.RGBWW not in supported_color_modes - ): - rgbww_color = params.pop(ATTR_RGBWW_COLOR) - assert rgbww_color is not None - if TYPE_CHECKING: - rgbww_color = cast(tuple[int, int, int, int, int], rgbww_color) - rgb_color = color_util.color_rgbww_to_rgb( - *rgbww_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin - ) - if ColorMode.RGB in supported_color_modes: - params[ATTR_RGB_COLOR] = rgb_color - elif ColorMode.RGBW in supported_color_modes: - params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color) - elif ColorMode.HS in supported_color_modes: - params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) - elif ColorMode.XY in supported_color_modes: - params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) - elif ColorMode.COLOR_TEMP in supported_color_modes: - xy_color = color_util.color_RGB_to_xy(*rgb_color) - params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( - *xy_color - ) - - # If white is set to True, set it to the light's brightness - # Add a warning in Home Assistant Core 2024.3 if the brightness is set to an - # integer. - if params.get(ATTR_WHITE) is True: - params[ATTR_WHITE] = light.brightness - - # If both white and brightness are specified, override white - if ATTR_WHITE in params and ColorMode.WHITE in supported_color_modes: - params[ATTR_WHITE] = params.pop(ATTR_BRIGHTNESS, params[ATTR_WHITE]) - - # Remove deprecated white value if the light supports color mode if params.get(ATTR_BRIGHTNESS) == 0 or params.get(ATTR_WHITE) == 0: await async_handle_light_off_service(light, call) else: @@ -510,10 +524,7 @@ async def async_handle_light_off_service( light: LightEntity, call: ServiceCall ) -> None: """Handle turning off a light.""" - params = dict(call.data["params"]) - - if ATTR_TRANSITION not in params: - profiles.apply_default(light.entity_id, True, params) + params = process_turn_off_params(hass, light, call.data["params"]) await light.async_turn_off(**filter_turn_off_params(light, params)) @@ -521,10 +532,7 @@ async def async_handle_toggle_service( light: LightEntity, call: ServiceCall ) -> None: """Handle toggling a light.""" - if light.is_on: - await async_handle_light_off_service(light, call) - else: - await async_handle_light_on_service(light, call) + await light.async_toggle(**call.data["params"]) # Listen for light on and light off service calls. @@ -1046,3 +1054,15 @@ def supported_color_modes(self) -> set[ColorMode] | None: def supported_features(self) -> LightEntityFeature: """Flag supported features.""" return self._attr_supported_features + + @override + async def async_toggle(self, **kwargs: Any) -> None: + """Toggle the entity.""" + if not self.is_on: + params = process_turn_on_params(self.hass, self, kwargs) + if params.get(ATTR_BRIGHTNESS) != 0 and params.get(ATTR_WHITE) != 0: + await self.async_turn_on(**filter_turn_on_params(self, params)) + return + + params = process_turn_off_params(self.hass, self, kwargs) + await self.async_turn_off(**filter_turn_off_params(self, params)) diff --git a/homeassistant/components/light/trigger.py b/homeassistant/components/light/trigger.py index 2e087b0039784e..3cbd9921789ec9 100644 --- a/homeassistant/components/light/trigger.py +++ b/homeassistant/components/light/trigger.py @@ -4,10 +4,11 @@ from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.helpers.automation import NumericalDomainSpec from homeassistant.helpers.trigger import ( - EntityNumericalStateAttributeChangedTriggerBase, - EntityNumericalStateAttributeCrossedThresholdTriggerBase, Trigger, + make_entity_numerical_state_changed_trigger, + make_entity_numerical_state_crossed_threshold_trigger, make_entity_target_state_trigger, ) @@ -20,28 +21,20 @@ def _convert_uint8_to_percentage(value: Any) -> float: return (float(value) / 255.0) * 100.0 -class BrightnessChangedTrigger(EntityNumericalStateAttributeChangedTriggerBase): - """Trigger for brightness changed.""" - - _domain = DOMAIN - _attribute = ATTR_BRIGHTNESS - - _converter = staticmethod(_convert_uint8_to_percentage) - - -class BrightnessCrossedThresholdTrigger( - EntityNumericalStateAttributeCrossedThresholdTriggerBase -): - """Trigger for brightness crossed threshold.""" - - _domain = DOMAIN - _attribute = ATTR_BRIGHTNESS - _converter = staticmethod(_convert_uint8_to_percentage) - +BRIGHTNESS_DOMAIN_SPECS = { + DOMAIN: NumericalDomainSpec( + value_source=ATTR_BRIGHTNESS, + value_converter=_convert_uint8_to_percentage, + ), +} TRIGGERS: dict[str, type[Trigger]] = { - "brightness_changed": BrightnessChangedTrigger, - "brightness_crossed_threshold": BrightnessCrossedThresholdTrigger, + "brightness_changed": make_entity_numerical_state_changed_trigger( + BRIGHTNESS_DOMAIN_SPECS + ), + "brightness_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( + BRIGHTNESS_DOMAIN_SPECS + ), "turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF), "turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON), } diff --git a/homeassistant/components/lightwave/climate.py b/homeassistant/components/lightwave/climate.py index 942fb4a1fbc031..136486f2492627 100644 --- a/homeassistant/components/lightwave/climate.py +++ b/homeassistant/components/lightwave/climate.py @@ -90,7 +90,7 @@ def update(self) -> None: self._attr_hvac_action = HVACAction.OFF @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Target room temperature.""" if self._inhibit > 0: # If we get an update before the new temp has diff --git a/homeassistant/components/linux_battery/sensor.py b/homeassistant/components/linux_battery/sensor.py index fffb6357a285af..e5f7370eb5f2ac 100644 --- a/homeassistant/components/linux_battery/sensor.py +++ b/homeassistant/components/linux_battery/sensor.py @@ -4,6 +4,7 @@ import logging import os +from typing import Any from batinfo import Batteries import voluptuous as vol @@ -97,7 +98,7 @@ def __init__(self, name, battery_id, system): self._system = system @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the sensor.""" if self._system == "android": return { diff --git a/homeassistant/components/litterrobot/__init__.py b/homeassistant/components/litterrobot/__init__.py index 14902a57aa50db..1a9fda45c287ef 100644 --- a/homeassistant/components/litterrobot/__init__.py +++ b/homeassistant/components/litterrobot/__init__.py @@ -3,10 +3,15 @@ from __future__ import annotations import itertools +import logging -from homeassistant.const import Platform +from pylitterbot import Account +from pylitterbot.exceptions import LitterRobotException + +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, 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.device_registry import DeviceEntry from homeassistant.helpers.typing import ConfigType @@ -14,6 +19,8 @@ from .coordinator import LitterRobotConfigEntry, LitterRobotDataUpdateCoordinator from .services import async_setup_services +_LOGGER = logging.getLogger(__name__) + CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [ Platform.BINARY_SENSOR, @@ -33,6 +40,50 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +async def async_migrate_entry( + hass: HomeAssistant, entry: LitterRobotConfigEntry +) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating configuration from version %s.%s", + entry.version, + entry.minor_version, + ) + + if entry.version > 1: + return False + + if entry.minor_version < 2: + account = Account(websession=async_get_clientsession(hass)) + try: + await account.connect( + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + ) + user_id = account.user_id + except LitterRobotException: + _LOGGER.debug("Could not connect to set unique_id during migration") + return False + finally: + await account.disconnect() + + if user_id and not hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, user_id + ): + hass.config_entries.async_update_entry( + entry, unique_id=user_id, minor_version=2 + ) + else: + hass.config_entries.async_update_entry(entry, minor_version=2) + + _LOGGER.debug( + "Migration to configuration version %s.%s successful", + entry.version, + entry.minor_version, + ) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: LitterRobotConfigEntry) -> bool: """Set up Litter-Robot from a config entry.""" coordinator = LitterRobotDataUpdateCoordinator(hass, entry) diff --git a/homeassistant/components/litterrobot/binary_sensor.py b/homeassistant/components/litterrobot/binary_sensor.py index d4df011d0aa0de..4dc64b08feca60 100644 --- a/homeassistant/components/litterrobot/binary_sensor.py +++ b/homeassistant/components/litterrobot/binary_sensor.py @@ -20,6 +20,8 @@ from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity, _WhiskerEntityT +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RobotBinarySensorEntityDescription( @@ -76,15 +78,27 @@ async def async_setup_entry( ) -> None: """Set up Litter-Robot binary sensors using config entry.""" coordinator = entry.runtime_data - async_add_entities( - LitterRobotBinarySensorEntity( - robot=robot, coordinator=coordinator, description=description - ) - for robot in coordinator.account.robots - for robot_type, entity_descriptions in BINARY_SENSOR_MAP.items() - if isinstance(robot, robot_type) - for description in entity_descriptions - ) + known_robots: set[str] = set() + + def _check_robots() -> None: + all_robots = coordinator.account.robots + current_robots = {robot.serial for robot in all_robots} + new_robots = current_robots - known_robots + if new_robots: + known_robots.update(new_robots) + async_add_entities( + LitterRobotBinarySensorEntity( + robot=robot, coordinator=coordinator, description=description + ) + for robot in all_robots + if robot.serial in new_robots + for robot_type, entity_descriptions in BINARY_SENSOR_MAP.items() + if isinstance(robot, robot_type) + for description in entity_descriptions + ) + + _check_robots() + entry.async_on_unload(coordinator.async_add_listener(_check_robots)) class LitterRobotBinarySensorEntity( diff --git a/homeassistant/components/litterrobot/button.py b/homeassistant/components/litterrobot/button.py index da6ac53ccec070..b1b44bc58a7f96 100644 --- a/homeassistant/components/litterrobot/button.py +++ b/homeassistant/components/litterrobot/button.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from typing import Any, Generic -from pylitterbot import FeederRobot, LitterRobot3, LitterRobot4, Robot +from pylitterbot import FeederRobot, LitterRobot3, LitterRobot4, LitterRobot5, Robot from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory @@ -14,7 +14,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import LitterRobotConfigEntry -from .entity import LitterRobotEntity, _WhiskerEntityT +from .entity import LitterRobotEntity, _WhiskerEntityT, whisker_command + +PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) @@ -24,20 +26,24 @@ class RobotButtonEntityDescription(ButtonEntityDescription, Generic[_WhiskerEnti press_fn: Callable[[_WhiskerEntityT], Coroutine[Any, Any, bool]] -ROBOT_BUTTON_MAP: dict[type[Robot], RobotButtonEntityDescription] = { - LitterRobot3: RobotButtonEntityDescription[LitterRobot3]( +ROBOT_BUTTON_MAP: dict[tuple[type[Robot], ...], RobotButtonEntityDescription] = { + (LitterRobot3, LitterRobot5): RobotButtonEntityDescription[ + LitterRobot3 | LitterRobot5 + ]( key="reset_waste_drawer", translation_key="reset_waste_drawer", entity_category=EntityCategory.CONFIG, press_fn=lambda robot: robot.reset_waste_drawer(), ), - LitterRobot4: RobotButtonEntityDescription[LitterRobot4]( + (LitterRobot4, LitterRobot5): RobotButtonEntityDescription[ + LitterRobot4 | LitterRobot5 + ]( key="reset", translation_key="reset", entity_category=EntityCategory.CONFIG, press_fn=lambda robot: robot.reset(), ), - FeederRobot: RobotButtonEntityDescription[FeederRobot]( + (FeederRobot,): RobotButtonEntityDescription[FeederRobot]( key="give_snack", translation_key="give_snack", press_fn=lambda robot: robot.give_snack(), @@ -52,14 +58,26 @@ async def async_setup_entry( ) -> None: """Set up Litter-Robot cleaner using config entry.""" coordinator = entry.runtime_data - async_add_entities( - LitterRobotButtonEntity( - robot=robot, coordinator=coordinator, description=description - ) - for robot in coordinator.account.robots - for robot_type, description in ROBOT_BUTTON_MAP.items() - if isinstance(robot, robot_type) - ) + known_robots: set[str] = set() + + def _check_robots() -> None: + all_robots = coordinator.account.robots + current_robots = {robot.serial for robot in all_robots} + new_robots = current_robots - known_robots + if new_robots: + known_robots.update(new_robots) + async_add_entities( + LitterRobotButtonEntity( + robot=robot, coordinator=coordinator, description=description + ) + for robot in all_robots + if robot.serial in new_robots + for robot_type, description in ROBOT_BUTTON_MAP.items() + if isinstance(robot, robot_type) + ) + + _check_robots() + entry.async_on_unload(coordinator.async_add_listener(_check_robots)) class LitterRobotButtonEntity(LitterRobotEntity[_WhiskerEntityT], ButtonEntity): @@ -67,6 +85,7 @@ class LitterRobotButtonEntity(LitterRobotEntity[_WhiskerEntityT], ButtonEntity): entity_description: RobotButtonEntityDescription[_WhiskerEntityT] + @whisker_command async def async_press(self) -> None: """Press the button.""" await self.entity_description.press_fn(self.robot) diff --git a/homeassistant/components/litterrobot/config_flow.py b/homeassistant/components/litterrobot/config_flow.py index 90f1fcba56d25e..98fe97e74b27e7 100644 --- a/homeassistant/components/litterrobot/config_flow.py +++ b/homeassistant/components/litterrobot/config_flow.py @@ -10,7 +10,7 @@ from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -21,14 +21,17 @@ STEP_USER_DATA_SCHEMA = vol.Schema( {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} ) +STEP_REAUTH_RECONFIGURE_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) class LitterRobotConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Litter-Robot.""" VERSION = 1 + MINOR_VERSION = 2 username: str + _account_user_id: str | None = None async def async_step_reauth( self, entry_data: Mapping[str, Any] @@ -41,22 +44,45 @@ async def async_step_reauth_confirm( self, user_input: dict[str, str] | None = None ) -> ConfigFlowResult: """Handle user's reauth credentials.""" - errors = {} + errors: dict[str, str] = {} if user_input: - user_input = user_input | {CONF_USERNAME: self.username} - if not (error := await self._async_validate_input(user_input)): - return self.async_update_reload_and_abort( - self._get_reauth_entry(), data_updates=user_input - ) + reauth_entry = self._get_reauth_entry() + result, errors = await self._async_validate_and_update_entry( + reauth_entry, user_input + ) + if result is not None: + return result - errors["base"] = error return self.async_show_form( step_id="reauth_confirm", - data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}), + data_schema=STEP_REAUTH_RECONFIGURE_SCHEMA, description_placeholders={CONF_USERNAME: self.username}, errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow request.""" + reconfigure_entry = self._get_reconfigure_entry() + self.username = reconfigure_entry.data[CONF_USERNAME] + + self._async_abort_entries_match({CONF_USERNAME: self.username}) + + errors: dict[str, str] = {} + if user_input: + result, errors = await self._async_validate_and_update_entry( + reconfigure_entry, user_input + ) + if result is not None: + return result + + return self.async_show_form( + step_id="reconfigure", + data_schema=STEP_REAUTH_RECONFIGURE_SCHEMA, + errors=errors, + ) + async def async_step_user( self, user_input: Mapping[str, Any] | None = None ) -> ConfigFlowResult: @@ -65,8 +91,9 @@ async def async_step_user( if user_input is not None: self._async_abort_entries_match({CONF_USERNAME: user_input[CONF_USERNAME]}) - if not (error := await self._async_validate_input(user_input)): + await self.async_set_unique_id(self._account_user_id) + self._abort_if_unique_id_configured() return self.async_create_entry( title=user_input[CONF_USERNAME], data=user_input ) @@ -76,6 +103,25 @@ async def async_step_user( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + async def _async_validate_and_update_entry( + self, entry: ConfigEntry, user_input: dict[str, Any] + ) -> tuple[ConfigFlowResult | None, dict[str, str]]: + """Validate credentials and update an existing entry if valid.""" + errors: dict[str, str] = {} + full_input: dict[str, Any] = user_input | {CONF_USERNAME: self.username} + if not (error := await self._async_validate_input(full_input)): + await self.async_set_unique_id(self._account_user_id) + self._abort_if_unique_id_mismatch() + return ( + self.async_update_reload_and_abort( + entry, + data_updates=full_input, + ), + errors, + ) + errors["base"] = error + return None, errors + async def _async_validate_input(self, user_input: Mapping[str, Any]) -> str: """Validate login credentials.""" account = Account(websession=async_get_clientsession(self.hass)) @@ -92,4 +138,7 @@ async def _async_validate_input(self, user_input: Mapping[str, Any]) -> str: except Exception: _LOGGER.exception("Unexpected exception") return "unknown" + self._account_user_id = account.user_id + if not self._account_user_id: + return "unknown" return "" diff --git a/homeassistant/components/litterrobot/coordinator.py b/homeassistant/components/litterrobot/coordinator.py index 581257ab2dbb18..46005c34120ab6 100644 --- a/homeassistant/components/litterrobot/coordinator.py +++ b/homeassistant/components/litterrobot/coordinator.py @@ -13,6 +13,7 @@ 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_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -43,14 +44,52 @@ def __init__( ) self.account = Account(websession=async_get_clientsession(hass)) + self.previous_members: set[str] = set() + + # Initialize previous_members from the device registry so that + # stale devices can be detected on the first update after restart. + device_registry = dr.async_get(hass) + for device in dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ): + for domain, identifier in device.identifiers: + if domain == DOMAIN: + self.previous_members.add(identifier) async def _async_update_data(self) -> None: """Update all device states from the Litter-Robot API.""" - await self.account.refresh_robots() - await self.account.load_pets() - for pet in self.account.pets: - # Need to fetch weight history for `get_visits_since` - await pet.fetch_weight_history() + try: + await self.account.load_robots(subscribe_for_updates=True) + await self.account.load_pets() + for pet in self.account.pets: + # Need to fetch weight history for `get_visits_since` + await pet.fetch_weight_history() + except LitterRobotLoginException as ex: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="invalid_credentials" + ) from ex + except LitterRobotException as ex: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": str(ex)}, + ) from ex + + current_members = {robot.serial for robot in self.account.robots} | { + pet.id for pet in self.account.pets + } + if stale_members := self.previous_members - current_members: + device_registry = dr.async_get(self.hass) + for device_id in stale_members: + device = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ) + if device: + device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + self.previous_members = current_members async def _async_setup(self) -> None: """Set up the coordinator.""" @@ -63,9 +102,15 @@ async def _async_setup(self) -> None: load_pets=True, ) except LitterRobotLoginException as ex: - raise ConfigEntryAuthFailed("Invalid credentials") from ex + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="invalid_credentials" + ) from ex except LitterRobotException as ex: - raise UpdateFailed("Unable to connect to Litter-Robot API") from ex + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": str(ex)}, + ) from ex def litter_robots(self) -> Generator[LitterRobot]: """Get Litter-Robots from the account.""" diff --git a/homeassistant/components/litterrobot/diagnostics.py b/homeassistant/components/litterrobot/diagnostics.py new file mode 100644 index 00000000000000..4cdd8cb1a8c31b --- /dev/null +++ b/homeassistant/components/litterrobot/diagnostics.py @@ -0,0 +1,24 @@ +"""Diagnostics support for Litter-Robot.""" + +from __future__ import annotations + +from typing import Any + +from pylitterbot.utils import REDACT_FIELDS + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from .coordinator import LitterRobotConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: LitterRobotConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + account = entry.runtime_data.account + data = { + "robots": [robot.to_dict() for robot in account.robots], + "pets": [pet.to_dict() for pet in account.pets], + } + return async_redact_data(data, REDACT_FIELDS) diff --git a/homeassistant/components/litterrobot/entity.py b/homeassistant/components/litterrobot/entity.py index 4117069aa0e7e4..34478da837ab29 100644 --- a/homeassistant/components/litterrobot/entity.py +++ b/homeassistant/components/litterrobot/entity.py @@ -2,11 +2,14 @@ from __future__ import annotations -from typing import Generic, TypeVar +from collections.abc import Awaitable, Callable, Coroutine +from typing import Any, Concatenate, Generic, TypeVar from pylitterbot import Pet, Robot +from pylitterbot.exceptions import LitterRobotException from pylitterbot.robot import EVENT_UPDATE +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -17,6 +20,26 @@ _WhiskerEntityT = TypeVar("_WhiskerEntityT", bound=Robot | Pet) +def whisker_command[_WhiskerEntityT2: LitterRobotEntity, **_P]( + func: Callable[Concatenate[_WhiskerEntityT2, _P], Awaitable[None]], +) -> Callable[Concatenate[_WhiskerEntityT2, _P], Coroutine[Any, Any, None]]: + """Wrap a Whisker command to handle exceptions.""" + + async def handler( + self: _WhiskerEntityT2, *args: _P.args, **kwargs: _P.kwargs + ) -> None: + try: + await func(self, *args, **kwargs) + except LitterRobotException as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={"error": str(ex)}, + ) from ex + + return handler + + def get_device_info(whisker_entity: Robot | Pet) -> DeviceInfo: """Get device info for a robot or pet.""" if isinstance(whisker_entity, Robot): diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index 99e30167cc490b..de14c1796d4ce1 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -1,6 +1,6 @@ { "domain": "litterrobot", - "name": "Litter-Robot", + "name": "Whisker", "codeowners": ["@natekspencer", "@tkdrob"], "config_flow": true, "dhcp": [ @@ -12,6 +12,6 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["pylitterbot"], - "quality_scale": "bronze", - "requirements": ["pylitterbot==2025.0.0"] + "quality_scale": "silver", + "requirements": ["pylitterbot==2025.1.0"] } diff --git a/homeassistant/components/litterrobot/quality_scale.yaml b/homeassistant/components/litterrobot/quality_scale.yaml index 3b26500da97915..6500573dea7e78 100644 --- a/homeassistant/components/litterrobot/quality_scale.yaml +++ b/homeassistant/components/litterrobot/quality_scale.yaml @@ -23,59 +23,48 @@ rules: unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: status: done comment: No options to configure docs-installation-parameters: done - entity-unavailable: todo + entity-unavailable: done integration-owner: done - log-when-unavailable: todo - parallel-updates: todo + log-when-unavailable: done + parallel-updates: done reauthentication-flow: done - test-coverage: - status: todo - comment: | - Move big data objects from common.py into JSON fixtures and oad them when needed. - Other fields can be moved to const.py. Consider snapshots and testing data updates + test-coverage: done # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: done comment: The integration is cloud-based discovery: status: todo comment: Need to validate discovery - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done docs-supported-functions: done - docs-troubleshooting: todo - docs-use-cases: todo - dynamic-devices: todo + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: done - entity-translations: - status: todo - comment: Make sure all translated states are in sentence case - exception-translations: todo + entity-translations: done + exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: done comment: | This integration doesn't have any cases where raising an issue is needed - stale-devices: - status: todo - comment: | - Currently handled via async_remove_config_entry_device, - but we should be able to remove devices automatically - + stale-devices: done # Platinum async-dependency: done inject-websession: done diff --git a/homeassistant/components/litterrobot/select.py b/homeassistant/components/litterrobot/select.py index 9bf8691cc8a08d..a32f353ae8d2b0 100644 --- a/homeassistant/components/litterrobot/select.py +++ b/homeassistant/components/litterrobot/select.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from typing import Any, Generic, TypeVar -from pylitterbot import FeederRobot, LitterRobot, LitterRobot4, Robot +from pylitterbot import FeederRobot, LitterRobot, LitterRobot4, LitterRobot5, Robot from pylitterbot.robot.litterrobot4 import BrightnessLevel, NightLightMode from homeassistant.components.select import SelectEntity, SelectEntityDescription @@ -15,7 +15,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import LitterRobotConfigEntry, LitterRobotDataUpdateCoordinator -from .entity import LitterRobotEntity, _WhiskerEntityT +from .entity import LitterRobotEntity, _WhiskerEntityT, whisker_command + +PARALLEL_UPDATES = 1 _CastTypeT = TypeVar("_CastTypeT", int, float, str) @@ -32,9 +34,11 @@ class RobotSelectEntityDescription( select_fn: Callable[[_WhiskerEntityT, str], Coroutine[Any, Any, bool]] -ROBOT_SELECT_MAP: dict[type[Robot], tuple[RobotSelectEntityDescription, ...]] = { +ROBOT_SELECT_MAP: dict[ + type[Robot] | tuple[type[Robot], ...], tuple[RobotSelectEntityDescription, ...] +] = { LitterRobot: ( - RobotSelectEntityDescription[LitterRobot, int]( # type: ignore[type-abstract] # only used for isinstance check + RobotSelectEntityDescription[LitterRobot, int]( key="cycle_delay", translation_key="cycle_delay", unit_of_measurement=UnitOfTime.MINUTES, @@ -43,8 +47,8 @@ class RobotSelectEntityDescription( select_fn=lambda robot, opt: robot.set_wait_time(int(opt)), ), ), - LitterRobot4: ( - RobotSelectEntityDescription[LitterRobot4, str]( + (LitterRobot4, LitterRobot5): ( + RobotSelectEntityDescription[LitterRobot4 | LitterRobot5, str]( key="globe_brightness", translation_key="globe_brightness", current_fn=( @@ -61,7 +65,7 @@ class RobotSelectEntityDescription( ) ), ), - RobotSelectEntityDescription[LitterRobot4, str]( + RobotSelectEntityDescription[LitterRobot4 | LitterRobot5, str]( key="globe_light", translation_key="globe_light", current_fn=( @@ -78,7 +82,7 @@ class RobotSelectEntityDescription( ) ), ), - RobotSelectEntityDescription[LitterRobot4, str]( + RobotSelectEntityDescription[LitterRobot4 | LitterRobot5, str]( key="panel_brightness", translation_key="brightness_level", current_fn=( @@ -116,15 +120,27 @@ async def async_setup_entry( ) -> None: """Set up Litter-Robot selects using config entry.""" coordinator = entry.runtime_data - async_add_entities( - LitterRobotSelectEntity( - robot=robot, coordinator=coordinator, description=description - ) - for robot in coordinator.account.robots - for robot_type, descriptions in ROBOT_SELECT_MAP.items() - if isinstance(robot, robot_type) - for description in descriptions - ) + known_robots: set[str] = set() + + def _check_robots() -> None: + all_robots = coordinator.account.robots + current_robots = {robot.serial for robot in all_robots} + new_robots = current_robots - known_robots + if new_robots: + known_robots.update(new_robots) + async_add_entities( + LitterRobotSelectEntity( + robot=robot, coordinator=coordinator, description=description + ) + for robot in all_robots + if robot.serial in new_robots + for robot_type, descriptions in ROBOT_SELECT_MAP.items() + if isinstance(robot, robot_type) + for description in descriptions + ) + + _check_robots() + entry.async_on_unload(coordinator.async_add_listener(_check_robots)) class LitterRobotSelectEntity( @@ -152,6 +168,7 @@ def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" return str(self.entity_description.current_fn(self.robot)) + @whisker_command async def async_select_option(self, option: str) -> None: """Change the selected option.""" await self.entity_description.select_fn(self.robot, option) diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py index 7f408a5afb6d70..51bfecfbf25739 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -7,7 +7,7 @@ from datetime import datetime from typing import Any, Generic -from pylitterbot import FeederRobot, LitterRobot, LitterRobot4, Pet, Robot +from pylitterbot import FeederRobot, LitterRobot, LitterRobot4, LitterRobot5, Pet, Robot from homeassistant.components.sensor import ( SensorDeviceClass, @@ -23,6 +23,8 @@ from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity, _WhiskerEntityT +PARALLEL_UPDATES = 0 + def icon_for_gauge_level(gauge_level: int | None = None, offset: int = 0) -> str: """Return a gauge icon valid identifier.""" @@ -44,8 +46,10 @@ class RobotSensorEntityDescription(SensorEntityDescription, Generic[_WhiskerEnti value_fn: Callable[[_WhiskerEntityT], float | datetime | str | None] -ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { - LitterRobot: [ # type: ignore[type-abstract] # only used for isinstance check +ROBOT_SENSOR_MAP: dict[ + type[Robot] | tuple[type[Robot], ...], list[RobotSensorEntityDescription] +] = { + LitterRobot: [ RobotSensorEntityDescription[LitterRobot]( key="waste_drawer_level", translation_key="waste_drawer", @@ -145,7 +149,9 @@ class RobotSensorEntityDescription(SensorEntityDescription, Generic[_WhiskerEnti ) ), ), - RobotSensorEntityDescription[LitterRobot4]( + ], + (LitterRobot4, LitterRobot5): [ + RobotSensorEntityDescription[LitterRobot4 | LitterRobot5]( key="litter_level", translation_key="litter_level", native_unit_of_measurement=PERCENTAGE, @@ -153,7 +159,7 @@ class RobotSensorEntityDescription(SensorEntityDescription, Generic[_WhiskerEnti state_class=SensorStateClass.MEASUREMENT, value_fn=lambda robot: robot.litter_level, ), - RobotSensorEntityDescription[LitterRobot4]( + RobotSensorEntityDescription[LitterRobot4 | LitterRobot5]( key="pet_weight", translation_key="pet_weight", native_unit_of_measurement=UnitOfMass.POUNDS, @@ -226,23 +232,47 @@ async def async_setup_entry( ) -> None: """Set up Litter-Robot sensors using config entry.""" coordinator = entry.runtime_data - entities: list[LitterRobotSensorEntity] = [ - LitterRobotSensorEntity( - robot=robot, coordinator=coordinator, description=description - ) - for robot in coordinator.account.robots - for robot_type, entity_descriptions in ROBOT_SENSOR_MAP.items() - if isinstance(robot, robot_type) - for description in entity_descriptions - ] - entities.extend( - LitterRobotSensorEntity( - robot=pet, coordinator=coordinator, description=description - ) - for pet in coordinator.account.pets - for description in PET_SENSORS - ) - async_add_entities(entities) + known_robots: set[str] = set() + known_pets: set[str] = set() + + def _check_robots_and_pets() -> None: + entities: list[LitterRobotSensorEntity] = [] + + all_robots = coordinator.account.robots + current_robots = {robot.serial for robot in all_robots} + new_robots = current_robots - known_robots + if new_robots: + known_robots.update(new_robots) + entities.extend( + LitterRobotSensorEntity( + robot=robot, coordinator=coordinator, description=description + ) + for robot in all_robots + if robot.serial in new_robots + for robot_type, entity_descriptions in ROBOT_SENSOR_MAP.items() + if isinstance(robot, robot_type) + for description in entity_descriptions + ) + + all_pets = coordinator.account.pets + current_pets = {pet.id for pet in all_pets} + new_pets = current_pets - known_pets + if new_pets: + known_pets.update(new_pets) + entities.extend( + LitterRobotSensorEntity( + robot=pet, coordinator=coordinator, description=description + ) + for pet in all_pets + if pet.id in new_pets + for description in PET_SENSORS + ) + + if entities: + async_add_entities(entities) + + _check_robots_and_pets() + entry.async_on_unload(coordinator.async_add_listener(_check_robots_and_pets)) class LitterRobotSensorEntity(LitterRobotEntity[_WhiskerEntityT], SensorEntity): diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index f9e99b52b421cb..8efa6476a85dd5 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -2,7 +2,9 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_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%]", + "unique_id_mismatch": "The Whisker account does not match the previously configured account. Please re-authenticate using the same account, or remove this integration and set it up again if you want to use a different account." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -20,6 +22,14 @@ "description": "Please update your password for {username}", "title": "[%key:common::config_flow::title::reauth%]" }, + "reconfigure": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::litterrobot::config::step::user::data_description::password%]" + } + }, "user": { "data": { "password": "[%key:common::config_flow::data::password%]", @@ -194,10 +204,18 @@ } } }, - "issues": { - "deprecated_entity": { - "description": "The Litter-Robot entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your dashboards, automations and scripts, disable `{entity}` and reload the integration/restart Home Assistant to fix this issue.", - "title": "{name} is deprecated" + "exceptions": { + "cannot_connect": { + "message": "Unable to fetch data from the Whisker API: {error}" + }, + "command_failed": { + "message": "An error occurred while communicating with the device: {error}" + }, + "firmware_update_failed": { + "message": "Unable to start firmware update on {name}" + }, + "invalid_credentials": { + "message": "Invalid credentials. Please check your username and password, then try again" } }, "services": { diff --git a/homeassistant/components/litterrobot/switch.py b/homeassistant/components/litterrobot/switch.py index c9eff5be4c05d5..02eb37864f81dc 100644 --- a/homeassistant/components/litterrobot/switch.py +++ b/homeassistant/components/litterrobot/switch.py @@ -6,26 +6,17 @@ from dataclasses import dataclass from typing import Any, Generic -from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, LitterRobot4, Robot +from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, Robot -from homeassistant.components.switch import ( - DOMAIN as SWITCH_DOMAIN, - SwitchEntity, - SwitchEntityDescription, -) +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.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) -from .const import DOMAIN from .coordinator import LitterRobotConfigEntry -from .entity import LitterRobotEntity, _WhiskerEntityT +from .entity import LitterRobotEntity, _WhiskerEntityT, whisker_command + +PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) @@ -75,54 +66,27 @@ async def async_setup_entry( ) -> None: """Set up Litter-Robot switches using config entry.""" coordinator = entry.runtime_data - entities = [ - RobotSwitchEntity(robot=robot, coordinator=coordinator, description=description) - for robot in coordinator.account.robots - for robot_type, entity_descriptions in SWITCH_MAP.items() - if isinstance(robot, robot_type) - for description in entity_descriptions - ] - - ent_reg = er.async_get(hass) - - def add_deprecated_entity( - robot: LitterRobot4, - description: RobotSwitchEntityDescription, - entity_cls: type[RobotSwitchEntity], - ) -> None: - """Add deprecated entities.""" - unique_id = f"{robot.serial}-{description.key}" - if entity_id := ent_reg.async_get_entity_id(SWITCH_DOMAIN, DOMAIN, unique_id): - entity_entry = ent_reg.async_get(entity_id) - if entity_entry and entity_entry.disabled: - ent_reg.async_remove(entity_id) - async_delete_issue( - hass, - DOMAIN, - f"deprecated_entity_{unique_id}", + known_robots: set[str] = set() + + def _check_robots() -> None: + all_robots = coordinator.account.robots + current_robots = {robot.serial for robot in all_robots} + new_robots = current_robots - known_robots + if new_robots: + known_robots.update(new_robots) + async_add_entities( + RobotSwitchEntity( + robot=robot, coordinator=coordinator, description=description ) - elif entity_entry: - entities.append(entity_cls(robot, coordinator, description)) - async_create_issue( - hass, - DOMAIN, - f"deprecated_entity_{unique_id}", - breaks_in_ha_version="2026.4.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_entity", - translation_placeholders={ - "name": f"{robot.name} {entity_entry.name or entity_entry.original_name}", - "entity": entity_id, - }, - ) - - for robot in coordinator.account.get_robots(LitterRobot4): - add_deprecated_entity( - robot, NIGHT_LIGHT_MODE_ENTITY_DESCRIPTION, RobotSwitchEntity - ) + for robot in all_robots + if robot.serial in new_robots + for robot_type, entity_descriptions in SWITCH_MAP.items() + if isinstance(robot, robot_type) + for description in entity_descriptions + ) - async_add_entities(entities) + _check_robots() + entry.async_on_unload(coordinator.async_add_listener(_check_robots)) class RobotSwitchEntity(LitterRobotEntity[_WhiskerEntityT], SwitchEntity): @@ -135,10 +99,12 @@ def is_on(self) -> bool | None: """Return true if switch is on.""" return self.entity_description.value_fn(self.robot) + @whisker_command async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" await self.entity_description.set_fn(self.robot, True) + @whisker_command async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" await self.entity_description.set_fn(self.robot, False) diff --git a/homeassistant/components/litterrobot/time.py b/homeassistant/components/litterrobot/time.py index 3573418613b898..fa630625dcd820 100644 --- a/homeassistant/components/litterrobot/time.py +++ b/homeassistant/components/litterrobot/time.py @@ -16,7 +16,9 @@ from homeassistant.util import dt as dt_util from .coordinator import LitterRobotConfigEntry -from .entity import LitterRobotEntity, _WhiskerEntityT +from .entity import LitterRobotEntity, _WhiskerEntityT, whisker_command + +PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) @@ -53,15 +55,27 @@ async def async_setup_entry( ) -> None: """Set up Litter-Robot cleaner using config entry.""" coordinator = entry.runtime_data - async_add_entities( - LitterRobotTimeEntity( - robot=robot, - coordinator=coordinator, - description=LITTER_ROBOT_3_SLEEP_START, - ) - for robot in coordinator.litter_robots() - if isinstance(robot, LitterRobot3) - ) + known_robots: set[str] = set() + + def _check_robots() -> None: + all_robots = list(coordinator.litter_robots()) + current_robots = {robot.serial for robot in all_robots} + new_robots = current_robots - known_robots + if new_robots: + known_robots.update(new_robots) + async_add_entities( + LitterRobotTimeEntity( + robot=robot, + coordinator=coordinator, + description=LITTER_ROBOT_3_SLEEP_START, + ) + for robot in all_robots + if robot.serial in new_robots + if isinstance(robot, LitterRobot3) + ) + + _check_robots() + entry.async_on_unload(coordinator.async_add_listener(_check_robots)) class LitterRobotTimeEntity(LitterRobotEntity[_WhiskerEntityT], TimeEntity): @@ -74,6 +88,7 @@ def native_value(self) -> time | None: """Return the value reported by the time.""" return self.entity_description.value_fn(self.robot) + @whisker_command async def async_set_value(self, value: time) -> None: """Update the current value.""" await self.entity_description.set_fn(self.robot, value) diff --git a/homeassistant/components/litterrobot/update.py b/homeassistant/components/litterrobot/update.py index 8f3a176175b043..b94034a0e4430a 100644 --- a/homeassistant/components/litterrobot/update.py +++ b/homeassistant/components/litterrobot/update.py @@ -17,8 +17,11 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import DOMAIN from .coordinator import LitterRobotConfigEntry -from .entity import LitterRobotEntity +from .entity import LitterRobotEntity, whisker_command + +PARALLEL_UPDATES = 1 SCAN_INTERVAL = timedelta(days=1) @@ -36,14 +39,28 @@ async def async_setup_entry( ) -> None: """Set up Litter-Robot update platform.""" coordinator = entry.runtime_data - entities = ( - RobotUpdateEntity( - robot=robot, coordinator=coordinator, description=FIRMWARE_UPDATE_ENTITY - ) - for robot in coordinator.litter_robots() - if isinstance(robot, LitterRobot4) - ) - async_add_entities(entities, True) + known_robots: set[str] = set() + + def _check_robots() -> None: + all_robots = list(coordinator.litter_robots()) + current_robots = {robot.serial for robot in all_robots} + new_robots = current_robots - known_robots + if new_robots: + known_robots.update(new_robots) + entities = ( + RobotUpdateEntity( + robot=robot, + coordinator=coordinator, + description=FIRMWARE_UPDATE_ENTITY, + ) + for robot in all_robots + if robot.serial in new_robots + if isinstance(robot, LitterRobot4) + ) + async_add_entities(entities, True) + + _check_robots() + entry.async_on_unload(coordinator.async_add_listener(_check_robots)) class RobotUpdateEntity(LitterRobotEntity[LitterRobot4], UpdateEntity): @@ -80,11 +97,15 @@ async def async_update(self) -> None: latest_version = self.robot.firmware self._attr_latest_version = latest_version + @whisker_command async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install an update.""" if await self.robot.has_firmware_update(True): if not await self.robot.update_firmware(): - message = f"Unable to start firmware update on {self.robot.name}" - raise HomeAssistantError(message) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="firmware_update_failed", + translation_placeholders={"name": self.robot.name}, + ) diff --git a/homeassistant/components/litterrobot/vacuum.py b/homeassistant/components/litterrobot/vacuum.py index 5cda02f7114bed..bfd98dddac63b6 100644 --- a/homeassistant/components/litterrobot/vacuum.py +++ b/homeassistant/components/litterrobot/vacuum.py @@ -19,7 +19,9 @@ from homeassistant.util import dt as dt_util from .coordinator import LitterRobotConfigEntry -from .entity import LitterRobotEntity +from .entity import LitterRobotEntity, whisker_command + +PARALLEL_UPDATES = 1 LITTER_BOX_STATUS_STATE_MAP = { LitterBoxStatus.CLEAN_CYCLE: VacuumActivity.CLEANING, @@ -46,12 +48,24 @@ async def async_setup_entry( ) -> None: """Set up Litter-Robot cleaner using config entry.""" coordinator = entry.runtime_data - async_add_entities( - LitterRobotCleaner( - robot=robot, coordinator=coordinator, description=LITTER_BOX_ENTITY - ) - for robot in coordinator.litter_robots() - ) + known_robots: set[str] = set() + + def _check_robots() -> None: + all_robots = list(coordinator.litter_robots()) + current_robots = {robot.serial for robot in all_robots} + new_robots = current_robots - known_robots + if new_robots: + known_robots.update(new_robots) + async_add_entities( + LitterRobotCleaner( + robot=robot, coordinator=coordinator, description=LITTER_BOX_ENTITY + ) + for robot in all_robots + if robot.serial in new_robots + ) + + _check_robots() + entry.async_on_unload(coordinator.async_add_listener(_check_robots)) class LitterRobotCleaner(LitterRobotEntity[LitterRobot], StateVacuumEntity): @@ -66,15 +80,18 @@ def activity(self) -> VacuumActivity: """Return the state of the cleaner.""" return LITTER_BOX_STATUS_STATE_MAP.get(self.robot.status, VacuumActivity.ERROR) + @whisker_command async def async_start(self) -> None: """Start a clean cycle.""" await self.robot.set_power_status(True) await self.robot.start_cleaning() + @whisker_command async def async_stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" await self.robot.set_power_status(False) + @whisker_command async def async_set_sleep_mode( self, enabled: bool, start_time: str | None = None ) -> None: diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index dbeaca1b27afa9..f1d441af848360 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==12.1.3"] + "requirements": ["ical==13.2.2"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index 16e24217a1ba35..7bd47eb1d8c26b 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==12.1.3"] + "requirements": ["ical==13.2.2"] } diff --git a/homeassistant/components/locative/strings.json b/homeassistant/components/locative/strings.json index b43d634a8684c7..cd6996590f3cdc 100644 --- a/homeassistant/components/locative/strings.json +++ b/homeassistant/components/locative/strings.json @@ -2,6 +2,7 @@ "config": { "abort": { "cloud_not_connected": "[%key:common::config_flow::abort::cloud_not_connected%]", + "reconfigure_successful": "**Reconfiguration was successful**\n\nGo to webhooks in the Locative app and update webhook with the following settings:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details.", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]" }, @@ -9,6 +10,10 @@ "default": "To send locations to Home Assistant, you will need to set up the webhook feature in the Locative app.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details." }, "step": { + "reconfigure": { + "description": "Do you want to start reconfiguration?", + "title": "Reconfigure Locative webhook" + }, "user": { "description": "[%key:common::config_flow::description::confirm_setup%]", "title": "Set up the Locative webhook" diff --git a/homeassistant/components/lojack/__init__.py b/homeassistant/components/lojack/__init__.py new file mode 100644 index 00000000000000..4c691306c9a127 --- /dev/null +++ b/homeassistant/components/lojack/__init__.py @@ -0,0 +1,78 @@ +"""The LoJack integration for Home Assistant.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from lojack_api import ApiError, AuthenticationError, LoJackClient, Vehicle + +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 .coordinator import LoJackCoordinator + +PLATFORMS: list[Platform] = [Platform.DEVICE_TRACKER] + + +@dataclass +class LoJackData: + """Runtime data for a LoJack config entry.""" + + client: LoJackClient + coordinators: list[LoJackCoordinator] = field(default_factory=list) + + +type LoJackConfigEntry = ConfigEntry[LoJackData] + + +async def async_setup_entry(hass: HomeAssistant, entry: LoJackConfigEntry) -> bool: + """Set up LoJack from a config entry.""" + session = async_get_clientsession(hass) + + try: + client = await LoJackClient.create( + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + session=session, + ) + except AuthenticationError as err: + raise ConfigEntryAuthFailed(f"Authentication failed: {err}") from err + except ApiError as err: + raise ConfigEntryNotReady(f"API error during setup: {err}") from err + + try: + vehicles = await client.list_devices() + except AuthenticationError as err: + await client.close() + raise ConfigEntryAuthFailed(f"Authentication failed: {err}") from err + except ApiError as err: + await client.close() + raise ConfigEntryNotReady(f"API error during setup: {err}") from err + + data = LoJackData(client=client) + entry.runtime_data = data + + try: + for vehicle in vehicles or []: + if isinstance(vehicle, Vehicle): + coordinator = LoJackCoordinator(hass, client, entry, vehicle) + await coordinator.async_config_entry_first_refresh() + data.coordinators.append(coordinator) + except Exception: + await client.close() + raise + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: LoJackConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + await entry.runtime_data.client.close() + return unload_ok diff --git a/homeassistant/components/lojack/config_flow.py b/homeassistant/components/lojack/config_flow.py new file mode 100644 index 00000000000000..5fdc2fefb62939 --- /dev/null +++ b/homeassistant/components/lojack/config_flow.py @@ -0,0 +1,111 @@ +"""Config flow for LoJack integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from lojack_api import ApiError, AuthenticationError, LoJackClient +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class LoJackConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for LoJack.""" + + VERSION = 1 + MINOR_VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + async with await LoJackClient.create( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + session=async_get_clientsession(self.hass), + ) as client: + user_id = client.user_id + except AuthenticationError: + errors["base"] = "invalid_auth" + except ApiError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if not user_id: + errors["base"] = "unknown" + else: + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"LoJack ({user_input[CONF_USERNAME]})", + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle 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: + try: + async with await LoJackClient.create( + reauth_entry.data[CONF_USERNAME], + user_input[CONF_PASSWORD], + session=async_get_clientsession(self.hass), + ): + pass + except AuthenticationError: + errors["base"] = "invalid_auth" + except ApiError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + 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=vol.Schema({vol.Required(CONF_PASSWORD): str}), + description_placeholders={CONF_USERNAME: reauth_entry.data[CONF_USERNAME]}, + errors=errors, + ) diff --git a/homeassistant/components/lojack/const.py b/homeassistant/components/lojack/const.py new file mode 100644 index 00000000000000..4c395a43c25d02 --- /dev/null +++ b/homeassistant/components/lojack/const.py @@ -0,0 +1,13 @@ +"""Constants for the LoJack integration.""" + +from __future__ import annotations + +import logging +from typing import Final + +DOMAIN: Final = "lojack" + +LOGGER = logging.getLogger(__package__) + +# Default polling interval (in minutes) +DEFAULT_UPDATE_INTERVAL: Final = 5 diff --git a/homeassistant/components/lojack/coordinator.py b/homeassistant/components/lojack/coordinator.py new file mode 100644 index 00000000000000..ee76454296103c --- /dev/null +++ b/homeassistant/components/lojack/coordinator.py @@ -0,0 +1,68 @@ +"""Data update coordinator for the LoJack integration.""" + +from __future__ import annotations + +from datetime import timedelta +from typing import TYPE_CHECKING + +from lojack_api import ApiError, AuthenticationError, LoJackClient +from lojack_api.device import Vehicle +from lojack_api.models import Location + +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 DEFAULT_UPDATE_INTERVAL, DOMAIN, LOGGER + +if TYPE_CHECKING: + from . import LoJackConfigEntry + + +def get_device_name(vehicle: Vehicle) -> str: + """Get a human-readable name for a vehicle.""" + parts = [ + str(vehicle.year) if vehicle.year else None, + vehicle.make, + vehicle.model, + ] + name = " ".join(p for p in parts if p) + return name or vehicle.name or "Vehicle" + + +class LoJackCoordinator(DataUpdateCoordinator[Location]): + """Class to manage fetching LoJack data for a single vehicle.""" + + config_entry: LoJackConfigEntry + + def __init__( + self, + hass: HomeAssistant, + client: LoJackClient, + entry: ConfigEntry, + vehicle: Vehicle, + ) -> None: + """Initialize the coordinator.""" + self.client = client + self.vehicle = vehicle + + super().__init__( + hass, + LOGGER, + name=f"{DOMAIN}_{vehicle.id}", + update_interval=timedelta(minutes=DEFAULT_UPDATE_INTERVAL), + config_entry=entry, + ) + + async def _async_update_data(self) -> Location: + """Fetch location data for this vehicle.""" + try: + location = await self.vehicle.get_location(force=True) + except AuthenticationError as err: + raise ConfigEntryAuthFailed(f"Authentication failed: {err}") from err + except ApiError as err: + raise UpdateFailed(f"Error fetching data: {err}") from err + if location is None: + raise UpdateFailed("No location data available") + return location diff --git a/homeassistant/components/lojack/device_tracker.py b/homeassistant/components/lojack/device_tracker.py new file mode 100644 index 00000000000000..4b2539b9ecb2e8 --- /dev/null +++ b/homeassistant/components/lojack/device_tracker.py @@ -0,0 +1,78 @@ +"""Device tracker platform for LoJack integration.""" + +from __future__ import annotations + +from homeassistant.components.device_tracker import SourceType, TrackerEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import LoJackConfigEntry +from .const import DOMAIN +from .coordinator import LoJackCoordinator, get_device_name + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LoJackConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up LoJack device tracker from a config entry.""" + async_add_entities( + LoJackDeviceTracker(coordinator) + for coordinator in entry.runtime_data.coordinators + ) + + +class LoJackDeviceTracker(CoordinatorEntity[LoJackCoordinator], TrackerEntity): + """Representation of a LoJack device tracker.""" + + _attr_has_entity_name = True + _attr_name = None # Main entity of the device, uses device name directly + + def __init__(self, coordinator: LoJackCoordinator) -> None: + """Initialize the device tracker.""" + super().__init__(coordinator) + self._attr_unique_id = coordinator.vehicle.id + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + return DeviceInfo( + identifiers={(DOMAIN, self.coordinator.vehicle.id)}, + name=get_device_name(self.coordinator.vehicle), + manufacturer="Spireon LoJack", + model=self.coordinator.vehicle.model, + serial_number=self.coordinator.vehicle.vin, + ) + + @property + def source_type(self) -> SourceType: + """Return the source type of the device.""" + return SourceType.GPS + + @property + def latitude(self) -> float | None: + """Return the latitude of the device.""" + return self.coordinator.data.latitude + + @property + def longitude(self) -> float | None: + """Return the longitude of the device.""" + return self.coordinator.data.longitude + + @property + def location_accuracy(self) -> int: + """Return the location accuracy of the device.""" + if self.coordinator.data.accuracy is not None: + return int(self.coordinator.data.accuracy) + return 0 + + @property + def battery_level(self) -> int | None: + """Return the battery level of the device (if applicable).""" + # LoJack devices report vehicle battery voltage, not percentage + return None diff --git a/homeassistant/components/lojack/manifest.json b/homeassistant/components/lojack/manifest.json new file mode 100644 index 00000000000000..fa2e0fec4502d6 --- /dev/null +++ b/homeassistant/components/lojack/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "lojack", + "name": "LoJack", + "codeowners": ["@devinslick"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/lojack", + "integration_type": "hub", + "iot_class": "cloud_polling", + "loggers": ["lojack_api"], + "quality_scale": "silver", + "requirements": ["lojack-api==0.7.1"] +} diff --git a/homeassistant/components/lojack/quality_scale.yaml b/homeassistant/components/lojack/quality_scale.yaml new file mode 100644 index 00000000000000..3f319579a49812 --- /dev/null +++ b/homeassistant/components/lojack/quality_scale.yaml @@ -0,0 +1,81 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: 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: 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 provide actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration does not provide an options flow. + docs-installation-parameters: + status: done + comment: Documented in https://github.com/home-assistant/home-assistant.io/pull/43463 + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery: + status: exempt + comment: This is a cloud polling integration with no local discovery mechanism since the devices are not on a local network. + discovery-update-info: + status: exempt + comment: This is a cloud polling integration with no local discovery mechanism. + 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: Vehicles are tied to the user account. Changes require integration reload. + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: The device tracker entity is the primary entity and should be enabled by default. + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No user-actionable repair scenarios identified for this integration. + stale-devices: + status: exempt + comment: Vehicles removed from the LoJack account stop appearing in API responses and become unavailable. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/lojack/strings.json b/homeassistant/components/lojack/strings.json new file mode 100644 index 00000000000000..31bb0f2d31e955 --- /dev/null +++ b/homeassistant/components/lojack/strings.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "initiate_flow": { + "user": "[%key:common::config_flow::initiate_flow::account%]" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::lojack::config::step::user::data_description::password%]" + }, + "description": "Re-enter the password for {username}." + }, + "user": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "Your LoJack/Spireon account password", + "username": "Your LoJack/Spireon account email address" + }, + "description": "Enter your LoJack/Spireon account credentials." + } + } + } +} diff --git a/homeassistant/components/london_air/sensor.py b/homeassistant/components/london_air/sensor.py index b3c7535b9b7fb9..3560e9b332145e 100644 --- a/homeassistant/components/london_air/sensor.py +++ b/homeassistant/components/london_air/sensor.py @@ -5,6 +5,7 @@ from datetime import timedelta from http import HTTPStatus import logging +from typing import Any import requests import voluptuous as vol @@ -106,38 +107,22 @@ def update(self): class AirSensor(SensorEntity): """Single authority air sensor.""" - ICON = "mdi:cloud-outline" + _attr_icon = "mdi:cloud-outline" def __init__(self, name, api_data): """Initialize the sensor.""" - self._name = name + self._attr_name = self._key = name self._api_data = api_data self._site_data = None - self._state = None self._updated = None - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - @property def site_data(self): """Return the dict of sites data.""" return self._site_data @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self.ICON - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return other details about the sensor state.""" attrs = {} attrs["updated"] = self._updated @@ -150,7 +135,7 @@ def update(self) -> None: sites_status: list = [] self._api_data.update() if self._api_data.data: - self._site_data = self._api_data.data[self._name] + self._site_data = self._api_data.data[self._key] self._updated = self._site_data[0]["updated"] sites_status.extend( site["pollutants_status"] @@ -159,9 +144,9 @@ def update(self) -> None: ) if sites_status: - self._state = max(set(sites_status), key=sites_status.count) + self._attr_native_value = max(set(sites_status), key=sites_status.count) else: - self._state = None + self._attr_native_value = None def parse_species(species_data): diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 295042405ee77c..1513d1a68699ed 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -353,14 +353,13 @@ def _register_panel( kwargs = { "frontend_url_path": url_path, "require_admin": config[CONF_REQUIRE_ADMIN], + "show_in_sidebar": config[CONF_SHOW_IN_SIDEBAR], + "sidebar_title": config[CONF_TITLE], + "sidebar_icon": config.get(CONF_ICON, DEFAULT_ICON), "config": {"mode": mode}, "update": update, } - if config[CONF_SHOW_IN_SIDEBAR]: - kwargs["sidebar_title"] = config[CONF_TITLE] - kwargs["sidebar_icon"] = config.get(CONF_ICON, DEFAULT_ICON) - frontend.async_register_built_in_panel(hass, DOMAIN, **kwargs) diff --git a/homeassistant/components/lovelace/cast.py b/homeassistant/components/lovelace/cast.py index 85c10e76cdebb4..a0e6185b06f880 100644 --- a/homeassistant/components/lovelace/cast.py +++ b/homeassistant/components/lovelace/cast.py @@ -42,7 +42,7 @@ async def async_get_media_browser_root_object( media_class=MediaClass.APP, media_content_id="", media_content_type=DOMAIN, - thumbnail="https://brands.home-assistant.io/_/lovelace/logo.png", + thumbnail="/api/brands/integration/lovelace/logo.png", can_play=False, can_expand=True, ) @@ -72,7 +72,7 @@ async def async_browse_media( media_class=MediaClass.APP, media_content_id=DEFAULT_DASHBOARD, media_content_type=DOMAIN, - thumbnail="https://brands.home-assistant.io/_/lovelace/logo.png", + thumbnail="/api/brands/integration/lovelace/logo.png", can_play=True, can_expand=False, ) @@ -104,7 +104,7 @@ async def async_browse_media( media_class=MediaClass.APP, media_content_id=f"{info['url_path']}/{view['path']}", media_content_type=DOMAIN, - thumbnail="https://brands.home-assistant.io/_/lovelace/logo.png", + thumbnail="/api/brands/integration/lovelace/logo.png", can_play=True, can_expand=False, ) @@ -213,7 +213,7 @@ def _item_from_info(info: dict) -> BrowseMedia: media_class=MediaClass.APP, media_content_id=info["url_path"], media_content_type=DOMAIN, - thumbnail="https://brands.home-assistant.io/_/lovelace/logo.png", + thumbnail="/api/brands/integration/lovelace/logo.png", can_play=True, can_expand=len(info["views"]) > 1, ) diff --git a/homeassistant/components/lunatone/config_flow.py b/homeassistant/components/lunatone/config_flow.py index 4dc5d8c03ecf69..b5004ffdce4af7 100644 --- a/homeassistant/components/lunatone/config_flow.py +++ b/homeassistant/components/lunatone/config_flow.py @@ -22,11 +22,6 @@ ) -def compose_title(name: str | None, serial_number: int) -> str: - """Compose a title string from a given name and serial number.""" - return f"{name or 'DALI Gateway'} {serial_number}" - - class LunatoneConfigFlow(ConfigFlow, domain=DOMAIN): """Lunatone config flow.""" @@ -54,22 +49,17 @@ async def async_step_user( except aiohttp.ClientConnectionError: errors["base"] = "cannot_connect" else: - if info_api.data is None or info_api.serial_number is None: + if info_api.serial_number is None: errors["base"] = "missing_device_info" else: await self.async_set_unique_id(str(info_api.serial_number)) 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=compose_title(info_api.name, info_api.serial_number), + self._get_reconfigure_entry(), data_updates=data, title=url ) self._abort_if_unique_id_configured() - return self.async_create_entry( - title=compose_title(info_api.name, info_api.serial_number), - data={CONF_URL: url}, - ) + return self.async_create_entry(title=url, data={CONF_URL: url}) return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, diff --git a/homeassistant/components/lunatone/light.py b/homeassistant/components/lunatone/light.py index b32af40bca9d93..a733fd6588b0aa 100644 --- a/homeassistant/components/lunatone/light.py +++ b/homeassistant/components/lunatone/light.py @@ -109,14 +109,18 @@ def is_on(self) -> bool: return self._device is not None and self._device.is_on @property - def brightness(self) -> int: + def brightness(self) -> int | None: """Return the brightness of this light between 0..255.""" - return value_to_brightness(self.BRIGHTNESS_SCALE, self._device.brightness) + return ( + value_to_brightness(self.BRIGHTNESS_SCALE, self._device.brightness) + if self._device.brightness is not None + else None + ) @property def color_mode(self) -> ColorMode: """Return the color mode of the light.""" - if self._device is not None and self._device.is_dimmable: + if self._device is not None and self._device.brightness is not None: return ColorMode.BRIGHTNESS return ColorMode.ONOFF @@ -149,7 +153,8 @@ async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" if brightness_supported(self.supported_color_modes): - self._last_brightness = self.brightness + if self.brightness: + self._last_brightness = self.brightness await self._device.fade_to_brightness(0) else: await self._device.switch_off() diff --git a/homeassistant/components/lunatone/manifest.json b/homeassistant/components/lunatone/manifest.json index 24a2f1f3b39818..33ca0382fbb23e 100644 --- a/homeassistant/components/lunatone/manifest.json +++ b/homeassistant/components/lunatone/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["lunatone-rest-api-client==0.6.3"] + "requirements": ["lunatone-rest-api-client==0.7.0"] } diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py index 97823d404fc557..0a15d5a20f8fa1 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -2,6 +2,7 @@ from dataclasses import dataclass import logging +from typing import Any, cast from pylutron import Button, Keypad, Led, Lutron, OccupancyGroup, Output @@ -42,7 +43,7 @@ class LutronData: covers: list[tuple[str, Output]] fans: list[tuple[str, Output]] lights: list[tuple[str, Output]] - scenes: list[tuple[str, Keypad, Button, Led]] + scenes: list[tuple[str, Keypad, Button, Led | None]] switches: list[tuple[str, Output]] @@ -110,6 +111,14 @@ async def async_setup_entry( ) for keypad in area.keypads: + _async_check_keypad_identifiers( + hass, + device_registry, + keypad.id, + keypad.uuid, + keypad.legacy_uuid, + entry_data.client.guid, + ) 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 ( @@ -226,6 +235,36 @@ def _async_check_device_identifiers( ) +def _async_check_keypad_identifiers( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + keypad_id: int, + uuid: str, + legacy_uuid: str, + controller_guid: str, +) -> None: + """Migrate from integer based keypad.ids to proper uuids.""" + + # First check for the very old integer-based ID + # We use cast(Any, ...) here because legacy devices may have integer identifiers + # in the registry, but modern Home Assistant expects strings. + device = device_registry.async_get_device( + identifiers={(DOMAIN, cast(Any, keypad_id))} + ) + if device: + new_unique_id = f"{controller_guid}_{uuid or legacy_uuid}" + _LOGGER.debug("Updating keypad id from %d to %s", keypad_id, new_unique_id) + device_registry.async_update_device( + device.id, new_identifiers={(DOMAIN, new_unique_id)} + ) + return + + # Now handle legacy_uuid to uuid migration if needed + _async_check_device_identifiers( + hass, device_registry, uuid, legacy_uuid, controller_guid + ) + + async def async_unload_entry(hass: HomeAssistant, entry: LutronConfigEntry) -> bool: """Clean up resources and entities associated with the integration.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/lutron/config_flow.py b/homeassistant/components/lutron/config_flow.py index bd1cd107e8c79d..99b8a166b18f83 100644 --- a/homeassistant/components/lutron/config_flow.py +++ b/homeassistant/components/lutron/config_flow.py @@ -37,11 +37,12 @@ async def async_step_user( if user_input is not None: ip_address = user_input[CONF_HOST] + guid: str | None = None main_repeater = Lutron( ip_address, - user_input.get(CONF_USERNAME), - user_input.get(CONF_PASSWORD), + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], ) try: @@ -55,10 +56,11 @@ async def async_step_user( else: guid = main_repeater.guid - if len(guid) <= 10: + if guid is None or len(guid) <= 10: errors["base"] = "cannot_connect" if not errors: + assert guid is not None await self.async_set_unique_id(guid) self._abort_if_unique_id_configured() diff --git a/homeassistant/components/lutron/cover.py b/homeassistant/components/lutron/cover.py index 8909e49f7aa717..3956bb9f486504 100644 --- a/homeassistant/components/lutron/cover.py +++ b/homeassistant/components/lutron/cover.py @@ -75,7 +75,7 @@ def _update_attrs(self) -> None: """Update the state attributes.""" level = self._lutron_device.last_level() self._attr_is_closed = level < 1 - self._attr_current_cover_position = level + self._attr_current_cover_position = int(level) _LOGGER.debug("Lutron ID: %d updated to %f", self._lutron_device.id, level) @property diff --git a/homeassistant/components/lutron/entity.py b/homeassistant/components/lutron/entity.py index 3910ecfa0ba491..48f6541d9168be 100644 --- a/homeassistant/components/lutron/entity.py +++ b/homeassistant/components/lutron/entity.py @@ -43,10 +43,8 @@ def _update_callback( @property def unique_id(self) -> str: """Return a unique ID.""" - - if self._lutron_device.uuid is None: - return f"{self._controller.guid}_{self._lutron_device.legacy_uuid}" - return f"{self._controller.guid}_{self._lutron_device.uuid}" + device_uuid = self._lutron_device.uuid or self._lutron_device.legacy_uuid + return f"{self._controller.guid}_{device_uuid}" def update(self) -> None: """Update the entity's state.""" @@ -83,8 +81,9 @@ def __init__( ) -> None: """Initialize the device.""" super().__init__(area_name, lutron_device, controller) + device_uuid = keypad.uuid or keypad.legacy_uuid self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, keypad.id)}, + identifiers={(DOMAIN, f"{controller.guid}_{device_uuid}")}, manufacturer="Lutron", name=keypad.name, ) diff --git a/homeassistant/components/lutron/event.py b/homeassistant/components/lutron/event.py index d7ec85835b74f5..15b67c50727efa 100644 --- a/homeassistant/components/lutron/event.py +++ b/homeassistant/components/lutron/event.py @@ -1,8 +1,9 @@ """Support for Lutron events.""" from enum import StrEnum +from typing import cast -from pylutron import Button, Keypad, Lutron, LutronEvent +from pylutron import Button, Keypad, Lutron, LutronEntity, LutronEvent from homeassistant.components.event import EventEntity from homeassistant.const import ATTR_ID @@ -78,9 +79,10 @@ async def async_added_to_hass(self) -> None: @callback def handle_event( - self, button: Button, _context: None, event: LutronEvent, _params: dict + self, button: LutronEntity, _context: None, event: LutronEvent, _params: dict ) -> None: """Handle received event.""" + button = cast(Button, button) action: LutronEventType | None = None if self._has_release_event: if event == Button.Event.PRESSED: diff --git a/homeassistant/components/lutron/fan.py b/homeassistant/components/lutron/fan.py index cc63994cdbe5ee..d6a1168a2fe391 100644 --- a/homeassistant/components/lutron/fan.py +++ b/homeassistant/components/lutron/fan.py @@ -83,7 +83,7 @@ def _request_state(self) -> None: def _update_attrs(self) -> None: """Update the state attributes.""" - level = self._lutron_device.last_level() + level = int(self._lutron_device.last_level()) self._attr_is_on = level > 0 self._attr_percentage = level if self._prev_percentage is None or level != 0: diff --git a/homeassistant/components/lutron/light.py b/homeassistant/components/lutron/light.py index 955c4a2af90ea2..9216202bf7cafc 100644 --- a/homeassistant/components/lutron/light.py +++ b/homeassistant/components/lutron/light.py @@ -45,12 +45,12 @@ async def async_setup_entry( ) -def to_lutron_level(level): +def to_lutron_level(level: int) -> float: """Convert the given Home Assistant light level (0-255) to Lutron (0.0-100.0).""" return float((level * 100) / 255) -def to_hass_level(level): +def to_hass_level(level: float) -> int: """Convert the given Lutron (0.0-100.0) light level to Home Assistant (0-255).""" return int((level * 255) / 100) diff --git a/homeassistant/components/lutron/manifest.json b/homeassistant/components/lutron/manifest.json index 5351573c6e4c3e..e40203a6ccafed 100644 --- a/homeassistant/components/lutron/manifest.json +++ b/homeassistant/components/lutron/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pylutron"], - "requirements": ["pylutron==0.2.18"], + "requirements": ["pylutron==0.4.0"], "single_config_entry": true } diff --git a/homeassistant/components/lutron/switch.py b/homeassistant/components/lutron/switch.py index addde6f95aa4cc..be7fc8ea9e115f 100644 --- a/homeassistant/components/lutron/switch.py +++ b/homeassistant/components/lutron/switch.py @@ -87,11 +87,11 @@ def __init__( def turn_on(self, **kwargs: Any) -> None: """Turn the LED on.""" - self._lutron_device.state = 1 + self._lutron_device.state = Led.LED_ON def turn_off(self, **kwargs: Any) -> None: """Turn the LED off.""" - self._lutron_device.state = 0 + self._lutron_device.state = Led.LED_OFF @property def extra_state_attributes(self) -> Mapping[str, Any] | None: @@ -108,4 +108,4 @@ def _request_state(self) -> None: def _update_attrs(self) -> None: """Update the state attributes.""" - self._attr_is_on = self._lutron_device.last_state + self._attr_is_on = self._lutron_device.last_state != Led.LED_OFF diff --git a/homeassistant/components/lutron_caseta/binary_sensor.py b/homeassistant/components/lutron_caseta/binary_sensor.py index 4a92eb5c3b7432..f8de5c60df0e0d 100644 --- a/homeassistant/components/lutron_caseta/binary_sensor.py +++ b/homeassistant/components/lutron_caseta/binary_sensor.py @@ -1,5 +1,7 @@ """Support for Lutron Caseta Occupancy/Vacancy Sensors.""" +from typing import Any + from pylutron_caseta import OCCUPANCY_GROUP_OCCUPIED from homeassistant.components.binary_sensor import ( @@ -61,7 +63,7 @@ def __init__(self, device, data): self._attr_device_info[ATTR_SUGGESTED_AREA] = area @property - def is_on(self): + def is_on(self) -> bool: """Return the brightness of the light.""" return self._device["status"] == OCCUPANCY_GROUP_OCCUPIED @@ -83,6 +85,6 @@ def unique_id(self): return f"occupancygroup_{self._bridge_unique_id}_{self.device_id}" @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return {"device_id": self.device_id} diff --git a/homeassistant/components/lutron_caseta/entity.py b/homeassistant/components/lutron_caseta/entity.py index 8cae22f5042ed7..cde2cb52923709 100644 --- a/homeassistant/components/lutron_caseta/entity.py +++ b/homeassistant/components/lutron_caseta/entity.py @@ -93,7 +93,7 @@ def unique_id(self) -> str: return str(self._handle_none_serial(self.serial)) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" attributes = { "device_id": self.device_id, diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json index c54643ea07b235..f163307a782a97 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.26.0"], + "requirements": ["pylutron-caseta==0.27.0"], "zeroconf": [ { "properties": { diff --git a/homeassistant/components/lw12wifi/light.py b/homeassistant/components/lw12wifi/light.py index 7071cc9f4164af..9ea67f23c3e802 100644 --- a/homeassistant/components/lw12wifi/light.py +++ b/homeassistant/components/lw12wifi/light.py @@ -59,6 +59,7 @@ def setup_platform( class LW12WiFi(LightEntity): """LW-12 WiFi LED Controller.""" + _attr_assumed_state = True _attr_color_mode = ColorMode.HS _attr_should_poll = False _attr_supported_color_modes = {ColorMode.HS} @@ -71,52 +72,31 @@ def __init__(self, name, lw12_light): :param lw12_light: Instance of the LW12 controller. """ self._light = lw12_light - self._name = name - self._state = None + self._attr_name = name self._effect = None self._rgb_color = [255, 255, 255] - self._brightness = 255 + self._attr_brightness = 255 @property - def name(self): - """Return the display name of the controlled light.""" - return self._name - - @property - def brightness(self): - """Return the brightness of the light.""" - return self._brightness - - @property - def hs_color(self): + def hs_color(self) -> tuple[float, float]: """Read back the hue-saturation of the light.""" return color_util.color_RGB_to_hs(*self._rgb_color) @property - def effect(self): + def effect(self) -> str | None: """Return current light effect.""" if self._effect is None: return None return self._effect.replace("_", " ").title() @property - def is_on(self): - """Return true if light is on.""" - return self._state - - @property - def effect_list(self): + def effect_list(self) -> list[str]: """Return a list of available effects. Use the Enum element name for display. """ return [effect.name.replace("_", " ").title() for effect in lw12.LW12_EFFECT] - @property - def assumed_state(self) -> bool: - """Return True if unable to access real state of the entity.""" - return True - def turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" self._light.light_on() @@ -125,8 +105,8 @@ def turn_on(self, **kwargs: Any) -> None: self._light.set_color(*self._rgb_color) self._effect = None if ATTR_BRIGHTNESS in kwargs: - self._brightness = kwargs[ATTR_BRIGHTNESS] - brightness = int(self._brightness / 255 * 100) + self._attr_brightness = kwargs[ATTR_BRIGHTNESS] + brightness = int(self._attr_brightness / 255 * 100) self._light.set_light_option(lw12.LW12_LIGHT.BRIGHTNESS, brightness) if ATTR_EFFECT in kwargs: self._effect = kwargs[ATTR_EFFECT].replace(" ", "_").upper() @@ -142,9 +122,9 @@ def turn_on(self, **kwargs: Any) -> None: if ATTR_TRANSITION in kwargs: transition_speed = int(kwargs[ATTR_TRANSITION]) self._light.set_light_option(lw12.LW12_LIGHT.FLASH, transition_speed) - self._state = True + self._attr_is_on = True def turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" self._light.light_off() - self._state = False + self._attr_is_on = False diff --git a/homeassistant/components/mailgun/strings.json b/homeassistant/components/mailgun/strings.json index 50b2f9cbe65cef..f7cada0e942158 100644 --- a/homeassistant/components/mailgun/strings.json +++ b/homeassistant/components/mailgun/strings.json @@ -2,6 +2,7 @@ "config": { "abort": { "cloud_not_connected": "[%key:common::config_flow::abort::cloud_not_connected%]", + "reconfigure_successful": "**Reconfiguration was successful**\n\nGo to [webhooks in Mailgun]({mailgun_url}) and update the webhook with the following settings:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data.", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]" }, @@ -9,6 +10,10 @@ "default": "To send events to Home Assistant, you will need to set up a [webhook with Mailgun]({mailgun_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." }, "step": { + "reconfigure": { + "description": "Are you sure you want to reconfigure Mailgun?", + "title": "Reconfigure Mailgun webhook" + }, "user": { "description": "Are you sure you want to set up Mailgun?", "title": "Set up the Mailgun webhook" diff --git a/homeassistant/components/mastodon/__init__.py b/homeassistant/components/mastodon/__init__.py index 8e4910d937aa1f..15d9aec63335e1 100644 --- a/homeassistant/components/mastodon/__init__.py +++ b/homeassistant/components/mastodon/__init__.py @@ -9,6 +9,7 @@ Mastodon, MastodonError, MastodonNotFoundError, + MastodonUnauthorizedError, ) from homeassistant.const import ( @@ -18,7 +19,7 @@ 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 from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify @@ -48,6 +49,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: MastodonConfigEntry) -> entry, ) + except MastodonUnauthorizedError as error: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed", + ) from error except MastodonError as ex: raise ConfigEntryNotReady("Failed to connect") from ex diff --git a/homeassistant/components/mastodon/config_flow.py b/homeassistant/components/mastodon/config_flow.py index 6cc82fd50f1595..963df3d2193925 100644 --- a/homeassistant/components/mastodon/config_flow.py +++ b/homeassistant/components/mastodon/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any from mastodon.Mastodon import ( @@ -43,6 +44,28 @@ ): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)), } ) +REAUTH_SCHEMA = vol.Schema( + { + vol.Required( + CONF_ACCESS_TOKEN, + ): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)), + } +) +STEP_RECONFIGURE_SCHEMA = vol.Schema( + { + vol.Required( + CONF_CLIENT_ID, + ): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)), + vol.Required( + CONF_CLIENT_SECRET, + ): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)), + vol.Required( + CONF_ACCESS_TOKEN, + ): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)), + } +) + +EXAMPLE_URL = "https://mastodon.social" def base_url_from_url(url: str) -> str: @@ -50,18 +73,26 @@ def base_url_from_url(url: str) -> str: return str(URL(url).origin()) +def remove_email_link(account_name: str) -> str: + """Remove email link from account name.""" + + # Replaces the @ with a HTML entity to prevent mailto links. + return account_name.replace("@", "@") + + class MastodonConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" VERSION = 1 MINOR_VERSION = 2 + base_url: str + client_id: str + client_secret: str + access_token: str + def check_connection( self, - base_url: str, - client_id: str, - client_secret: str, - access_token: str, ) -> tuple[ InstanceV2 | Instance | None, Account | None, @@ -70,10 +101,10 @@ def check_connection( """Check connection to the Mastodon instance.""" try: client = create_mastodon_client( - base_url, - client_id, - client_secret, - access_token, + self.base_url, + self.client_id, + self.client_secret, + self.access_token, ) try: instance = client.instance_v2() @@ -117,12 +148,13 @@ async def async_step_user( if user_input: user_input[CONF_BASE_URL] = base_url_from_url(user_input[CONF_BASE_URL]) + self.base_url = user_input[CONF_BASE_URL] + self.client_id = user_input[CONF_CLIENT_ID] + self.client_secret = user_input[CONF_CLIENT_SECRET] + self.access_token = user_input[CONF_ACCESS_TOKEN] + instance, account, errors = await self.hass.async_add_executor_job( - self.check_connection, - user_input[CONF_BASE_URL], - user_input[CONF_CLIENT_ID], - user_input[CONF_CLIENT_SECRET], - user_input[CONF_ACCESS_TOKEN], + self.check_connection ) if not errors: @@ -137,5 +169,81 @@ async def async_step_user( return self.show_user_form( user_input, errors, - description_placeholders={"example_url": "https://mastodon.social"}, + description_placeholders={"example_url": EXAMPLE_URL}, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + self.base_url = entry_data[CONF_BASE_URL] + self.client_id = entry_data[CONF_CLIENT_ID] + self.client_secret = entry_data[CONF_CLIENT_SECRET] + self.access_token = entry_data[CONF_ACCESS_TOKEN] + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + errors: dict[str, str] = {} + if user_input: + self.access_token = user_input[CONF_ACCESS_TOKEN] + instance, account, errors = await self.hass.async_add_executor_job( + self.check_connection + ) + if not errors: + name = construct_mastodon_username(instance, account) + await self.async_set_unique_id(slugify(name)) + self._abort_if_unique_id_mismatch(reason="wrong_account") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={CONF_ACCESS_TOKEN: user_input[CONF_ACCESS_TOKEN]}, + ) + account_name = self._get_reauth_entry().title + return self.async_show_form( + step_id="reauth_confirm", + data_schema=REAUTH_SCHEMA, + errors=errors, + description_placeholders={ + "account_name": remove_email_link(account_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: + self.base_url = reconfigure_entry.data[CONF_BASE_URL] + self.client_id = user_input[CONF_CLIENT_ID] + self.client_secret = user_input[CONF_CLIENT_SECRET] + self.access_token = user_input[CONF_ACCESS_TOKEN] + instance, account, errors = await self.hass.async_add_executor_job( + self.check_connection + ) + if not errors: + name = construct_mastodon_username(instance, account) + await self.async_set_unique_id(slugify(name)) + self._abort_if_unique_id_mismatch(reason="wrong_account") + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={ + CONF_CLIENT_ID: user_input[CONF_CLIENT_ID], + CONF_CLIENT_SECRET: user_input[CONF_CLIENT_SECRET], + CONF_ACCESS_TOKEN: user_input[CONF_ACCESS_TOKEN], + }, + ) + account_name = reconfigure_entry.title + return self.async_show_form( + step_id="reconfigure", + data_schema=STEP_RECONFIGURE_SCHEMA, + errors=errors, + description_placeholders={ + "account_name": remove_email_link(account_name), + }, ) diff --git a/homeassistant/components/mastodon/const.py b/homeassistant/components/mastodon/const.py index b26aca307efa20..592b6a2300ebc1 100644 --- a/homeassistant/components/mastodon/const.py +++ b/homeassistant/components/mastodon/const.py @@ -21,3 +21,5 @@ ATTR_MEDIA = "media" ATTR_MEDIA_DESCRIPTION = "media_description" ATTR_LANGUAGE = "language" +ATTR_DURATION = "duration" +ATTR_HIDE_NOTIFICATIONS = "hide_notifications" diff --git a/homeassistant/components/mastodon/coordinator.py b/homeassistant/components/mastodon/coordinator.py index 99785eca80b995..5246bbd413af40 100644 --- a/homeassistant/components/mastodon/coordinator.py +++ b/homeassistant/components/mastodon/coordinator.py @@ -6,13 +6,20 @@ from datetime import timedelta from mastodon import Mastodon -from mastodon.Mastodon import Account, Instance, InstanceV2, MastodonError +from mastodon.Mastodon import ( + Account, + Instance, + InstanceV2, + MastodonError, + MastodonUnauthorizedError, +) 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 LOGGER +from .const import DOMAIN, LOGGER @dataclass @@ -51,6 +58,11 @@ async def _async_update_data(self) -> Account: account: Account = await self.hass.async_add_executor_job( self.client.account_verify_credentials ) + except MastodonUnauthorizedError as error: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed", + ) from error except MastodonError as ex: raise UpdateFailed(ex) from ex diff --git a/homeassistant/components/mastodon/icons.json b/homeassistant/components/mastodon/icons.json index 2883f2e857f10b..e9185ee13b18e2 100644 --- a/homeassistant/components/mastodon/icons.json +++ b/homeassistant/components/mastodon/icons.json @@ -35,8 +35,14 @@ "get_account": { "service": "mdi:account-search" }, + "mute_account": { + "service": "mdi:account-voice-off" + }, "post": { "service": "mdi:message-text" + }, + "unmute_account": { + "service": "mdi:account-voice" } } } diff --git a/homeassistant/components/mastodon/manifest.json b/homeassistant/components/mastodon/manifest.json index 157b2986c4d9a7..2de970e263caa0 100644 --- a/homeassistant/components/mastodon/manifest.json +++ b/homeassistant/components/mastodon/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["mastodon"], - "quality_scale": "bronze", + "quality_scale": "gold", "requirements": ["Mastodon.py==2.1.2"] } diff --git a/homeassistant/components/mastodon/quality_scale.yaml b/homeassistant/components/mastodon/quality_scale.yaml index ff3d4ad3db0bb4..f5788a81347bf3 100644 --- a/homeassistant/components/mastodon/quality_scale.yaml +++ b/homeassistant/components/mastodon/quality_scale.yaml @@ -34,10 +34,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: - status: todo - comment: | - Waiting to move to oAuth. + reauthentication-flow: done test-coverage: done # Gold devices: done @@ -52,11 +49,11 @@ rules: Web service does not support discovery. docs-data-update: done docs-examples: done - docs-known-limitations: todo + 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: | @@ -67,10 +64,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: - status: todo - comment: | - Waiting to move to OAuth. + reconfiguration-flow: done repair-issues: done stale-devices: status: exempt diff --git a/homeassistant/components/mastodon/services.py b/homeassistant/components/mastodon/services.py index dbb5fc2afdc9a3..2208588570c2f6 100644 --- a/homeassistant/components/mastodon/services.py +++ b/homeassistant/components/mastodon/services.py @@ -1,11 +1,18 @@ """Define services for the Mastodon integration.""" +from datetime import timedelta from enum import StrEnum from functools import partial +from math import isfinite from typing import Any from mastodon import Mastodon -from mastodon.Mastodon import Account, MastodonAPIError, MediaAttachment +from mastodon.Mastodon import ( + Account, + MastodonAPIError, + MastodonNotFoundError, + MediaAttachment, +) import voluptuous as vol from homeassistant.const import ATTR_CONFIG_ENTRY_ID @@ -17,11 +24,13 @@ callback, ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import service +from homeassistant.helpers import config_validation as cv, service from .const import ( ATTR_ACCOUNT_NAME, ATTR_CONTENT_WARNING, + ATTR_DURATION, + ATTR_HIDE_NOTIFICATIONS, ATTR_IDEMPOTENCY_KEY, ATTR_LANGUAGE, ATTR_MEDIA, @@ -34,6 +43,8 @@ from .coordinator import MastodonConfigEntry from .utils import get_media_type +MAX_DURATION_SECONDS = 315360000 # 10 years + class StatusVisibility(StrEnum): """StatusVisibility model.""" @@ -51,6 +62,27 @@ class StatusVisibility(StrEnum): vol.Required(ATTR_ACCOUNT_NAME): str, } ) +SERVICE_MUTE_ACCOUNT = "mute_account" +SERVICE_MUTE_ACCOUNT_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + vol.Required(ATTR_ACCOUNT_NAME): str, + vol.Optional(ATTR_DURATION): vol.All( + cv.time_period, + vol.Range( + min=timedelta(seconds=1), max=timedelta(seconds=MAX_DURATION_SECONDS) + ), + ), + vol.Optional(ATTR_HIDE_NOTIFICATIONS, default=True): bool, + } +) +SERVICE_UNMUTE_ACCOUNT = "unmute_account" +SERVICE_UNMUTE_ACCOUNT_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + vol.Required(ATTR_ACCOUNT_NAME): str, + } +) SERVICE_POST = "post" SERVICE_POST_SCHEMA = vol.Schema( { @@ -77,11 +109,40 @@ def async_setup_services(hass: HomeAssistant) -> None: schema=SERVICE_GET_ACCOUNT_SCHEMA, supports_response=SupportsResponse.ONLY, ) + hass.services.async_register( + DOMAIN, + SERVICE_MUTE_ACCOUNT, + _async_mute_account, + schema=SERVICE_MUTE_ACCOUNT_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_UNMUTE_ACCOUNT, + _async_unmute_account, + schema=SERVICE_UNMUTE_ACCOUNT_SCHEMA, + ) hass.services.async_register( DOMAIN, SERVICE_POST, _async_post, schema=SERVICE_POST_SCHEMA ) +async def _async_account_lookup( + hass: HomeAssistant, client: Mastodon, account_name: str +) -> Account: + """Lookup a Mastodon account by its username.""" + try: + account: Account = await hass.async_add_executor_job( + partial(client.account_lookup, acct=account_name) + ) + except MastodonNotFoundError: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="account_not_found", + translation_placeholders={"account_name": account_name}, + ) from None + return account + + async def _async_get_account(call: ServiceCall) -> ServiceResponse: """Get account information.""" entry: MastodonConfigEntry = service.async_get_config_entry( @@ -92,9 +153,7 @@ async def _async_get_account(call: ServiceCall) -> ServiceResponse: account_name: str = call.data[ATTR_ACCOUNT_NAME] try: - account: Account = await call.hass.async_add_executor_job( - partial(client.account_lookup, acct=account_name) - ) + account = await _async_account_lookup(call.hass, client, account_name) except MastodonAPIError as err: raise HomeAssistantError( translation_domain=DOMAIN, @@ -105,6 +164,72 @@ async def _async_get_account(call: ServiceCall) -> ServiceResponse: return {"account": account} +async def _async_mute_account(call: ServiceCall) -> ServiceResponse: + """Mute account.""" + entry: MastodonConfigEntry = service.async_get_config_entry( + call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] + ) + client = entry.runtime_data.client + + account_name: str = call.data[ATTR_ACCOUNT_NAME] + hide_notifications: bool = call.data[ATTR_HIDE_NOTIFICATIONS] + duration: int | None = None + if call.data.get(ATTR_DURATION) is not None: + td: timedelta = call.data[ATTR_DURATION] + duration_seconds = td.total_seconds() + + if not isfinite(duration_seconds) or duration_seconds > MAX_DURATION_SECONDS: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="mute_duration_too_long", + ) + + duration = int(duration_seconds) + + try: + account = await _async_account_lookup(call.hass, client, account_name) + await call.hass.async_add_executor_job( + partial( + client.account_mute, + id=account.id, + notifications=hide_notifications, + duration=duration, + ) + ) + except MastodonAPIError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_mute_account", + translation_placeholders={"account_name": account_name}, + ) from err + + return None + + +async def _async_unmute_account(call: ServiceCall) -> ServiceResponse: + """Unmute account.""" + entry: MastodonConfigEntry = service.async_get_config_entry( + call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] + ) + client = entry.runtime_data.client + + account_name: str = call.data[ATTR_ACCOUNT_NAME] + + try: + account = await _async_account_lookup(call.hass, client, account_name) + await call.hass.async_add_executor_job( + partial(client.account_unmute, id=account.id) + ) + except MastodonAPIError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_unmute_account", + translation_placeholders={"account_name": account_name}, + ) from err + + return None + + async def _async_post(call: ServiceCall) -> ServiceResponse: """Post a status.""" entry: MastodonConfigEntry = service.async_get_config_entry( diff --git a/homeassistant/components/mastodon/services.yaml b/homeassistant/components/mastodon/services.yaml index 9027c6f9fcc107..bdeefc8b570870 100644 --- a/homeassistant/components/mastodon/services.yaml +++ b/homeassistant/components/mastodon/services.yaml @@ -9,6 +9,38 @@ get_account: required: true selector: text: +mute_account: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: mastodon + account_name: + required: true + selector: + text: + duration: + required: false + selector: + duration: + enable_day: true + hide_notifications: + default: true + required: false + selector: + boolean: +unmute_account: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: mastodon + account_name: + required: true + selector: + text: post: fields: config_entry_id: diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json index b069e09b7abdf8..5bfc629f1f3fbf 100644 --- a/homeassistant/components/mastodon/strings.json +++ b/homeassistant/components/mastodon/strings.json @@ -1,7 +1,11 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "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%]", + "wrong_account": "You have to use the same account that was used to configure the integration." }, "error": { "network_error": "The Mastodon instance was not found.", @@ -9,6 +13,28 @@ "unknown": "Unknown error occurred when connecting to the Mastodon instance." }, "step": { + "reauth_confirm": { + "data": { + "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "access_token": "[%key:component::mastodon::config::step::user::data_description::access_token%]" + }, + "description": "Please reauthenticate {account_name} with Mastodon." + }, + "reconfigure": { + "data": { + "access_token": "[%key:common::config_flow::data::access_token%]", + "client_id": "[%key:component::mastodon::config::step::user::data::client_id%]", + "client_secret": "[%key:component::mastodon::config::step::user::data::client_secret%]" + }, + "data_description": { + "access_token": "[%key:component::mastodon::config::step::user::data_description::access_token%]", + "client_id": "[%key:component::mastodon::config::step::user::data_description::client_id%]", + "client_secret": "[%key:component::mastodon::config::step::user::data_description::client_secret%]" + }, + "description": "Reconfigure {account_name} with Mastodon." + }, "user": { "data": { "access_token": "[%key:common::config_flow::data::access_token%]", @@ -69,18 +95,33 @@ } }, "exceptions": { + "account_not_found": { + "message": "Mastodon account \"{account_name}\" not found." + }, + "auth_failed": { + "message": "Authentication failed, please reauthenticate with Mastodon." + }, "idempotency_key_too_short": { "message": "Idempotency key must be at least 4 characters long." }, + "mute_duration_too_long": { + "message": "Mute duration is too long." + }, "not_whitelisted_directory": { "message": "{media} is not a whitelisted directory." }, "unable_to_get_account": { "message": "Unable to get account \"{account_name}\"." }, + "unable_to_mute_account": { + "message": "Unable to mute account \"{account_name}\"" + }, "unable_to_send_message": { "message": "Unable to send message." }, + "unable_to_unmute_account": { + "message": "Unable to unmute account \"{account_name}\"" + }, "unable_to_upload_image": { "message": "Unable to upload image {media_path}." } @@ -110,6 +151,28 @@ }, "name": "Get account" }, + "mute_account": { + "description": "Mutes a Mastodon account.", + "fields": { + "account_name": { + "description": "The Mastodon account username to mute (e.g. @user@instance).", + "name": "Account name" + }, + "config_entry_id": { + "description": "Select the Mastodon instance to mute this account on.", + "name": "Mastodon instance" + }, + "duration": { + "description": "The duration to mute the account for (default: indefinitely).", + "name": "Duration" + }, + "hide_notifications": { + "description": "Hide notifications from this account while muted.", + "name": "Hide notifications" + } + }, + "name": "Mute account" + }, "post": { "description": "Posts a status on your Mastodon account.", "fields": { @@ -151,6 +214,20 @@ } }, "name": "Post" + }, + "unmute_account": { + "description": "Unmutes a Mastodon account.", + "fields": { + "account_name": { + "description": "The Mastodon account username to unmute (e.g. @user@instance).", + "name": "Account name" + }, + "config_entry_id": { + "description": "Select the Mastodon instance to unmute this account on.", + "name": "Mastodon instance" + } + }, + "name": "Unmute account" } } } diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json index 3e0baa5e1be6a6..2ad943a84903bc 100644 --- a/homeassistant/components/matrix/manifest.json +++ b/homeassistant/components/matrix/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["matrix_client"], "quality_scale": "legacy", - "requirements": ["matrix-nio==0.25.2", "Pillow==12.0.0", "aiofiles==24.1.0"] + "requirements": ["matrix-nio==0.25.2", "Pillow==12.1.1", "aiofiles==24.1.0"] } diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 5eec7c0ba77517..6f6f87cfc86cb0 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -124,6 +124,7 @@ # support fan-only mode. (0x0001, 0x0108), (0x0001, 0x010A), + (0x118C, 0x2022), (0x1209, 0x8000), (0x1209, 0x8001), (0x1209, 0x8002), diff --git a/homeassistant/components/matter/const.py b/homeassistant/components/matter/const.py index 8018d5e09edf7e..cb42401725a54a 100644 --- a/homeassistant/components/matter/const.py +++ b/homeassistant/components/matter/const.py @@ -2,6 +2,8 @@ import logging +from chip.clusters import Objects as clusters + ADDON_SLUG = "core_matter_server" CONF_INTEGRATION_CREATED_ADDON = "integration_created_addon" @@ -15,3 +17,100 @@ ID_TYPE_SERIAL = "serial" FEATUREMAP_ATTRIBUTE_ID = 65532 + +# --- Lock domain constants --- + +# Shared field keys +ATTR_CREDENTIAL_RULE = "credential_rule" +ATTR_MAX_CREDENTIALS_PER_USER = "max_credentials_per_user" +ATTR_MAX_PIN_USERS = "max_pin_users" +ATTR_MAX_RFID_USERS = "max_rfid_users" +ATTR_MAX_USERS = "max_users" +ATTR_SUPPORTS_USER_MGMT = "supports_user_management" +ATTR_USER_INDEX = "user_index" +ATTR_USER_NAME = "user_name" +ATTR_USER_STATUS = "user_status" +ATTR_USER_TYPE = "user_type" + +# Magic values +CLEAR_ALL_INDEX = 0xFFFE # Matter spec: pass to ClearUser/ClearCredential to clear all + +# Timed request timeout for lock commands that modify state. +# 10 seconds accounts for Thread network latency and retransmissions. +LOCK_TIMED_REQUEST_TIMEOUT_MS = 10000 + +# Credential field keys +ATTR_CREDENTIAL_DATA = "credential_data" +ATTR_CREDENTIAL_INDEX = "credential_index" +ATTR_CREDENTIAL_TYPE = "credential_type" + +# Credential type strings +CRED_TYPE_FACE = "face" +CRED_TYPE_FINGERPRINT = "fingerprint" +CRED_TYPE_FINGER_VEIN = "finger_vein" +CRED_TYPE_PIN = "pin" +CRED_TYPE_RFID = "rfid" + +# User status mapping (Matter DoorLock UserStatusEnum) +_UserStatus = clusters.DoorLock.Enums.UserStatusEnum +USER_STATUS_MAP: dict[int, str] = { + _UserStatus.kAvailable: "available", + _UserStatus.kOccupiedEnabled: "occupied_enabled", + _UserStatus.kOccupiedDisabled: "occupied_disabled", +} +USER_STATUS_REVERSE_MAP: dict[str, int] = {v: k for k, v in USER_STATUS_MAP.items()} + +# User type mapping (Matter DoorLock UserTypeEnum) +_UserType = clusters.DoorLock.Enums.UserTypeEnum +USER_TYPE_MAP: dict[int, str] = { + _UserType.kUnrestrictedUser: "unrestricted_user", + _UserType.kYearDayScheduleUser: "year_day_schedule_user", + _UserType.kWeekDayScheduleUser: "week_day_schedule_user", + _UserType.kProgrammingUser: "programming_user", + _UserType.kNonAccessUser: "non_access_user", + _UserType.kForcedUser: "forced_user", + _UserType.kDisposableUser: "disposable_user", + _UserType.kExpiringUser: "expiring_user", + _UserType.kScheduleRestrictedUser: "schedule_restricted_user", + _UserType.kRemoteOnlyUser: "remote_only_user", +} +USER_TYPE_REVERSE_MAP: dict[str, int] = {v: k for k, v in USER_TYPE_MAP.items()} + +# Credential type mapping (Matter DoorLock CredentialTypeEnum) +_CredentialType = clusters.DoorLock.Enums.CredentialTypeEnum +CREDENTIAL_TYPE_MAP: dict[int, str] = { + _CredentialType.kProgrammingPIN: "programming_pin", + _CredentialType.kPin: CRED_TYPE_PIN, + _CredentialType.kRfid: CRED_TYPE_RFID, + _CredentialType.kFingerprint: CRED_TYPE_FINGERPRINT, + _CredentialType.kFingerVein: CRED_TYPE_FINGER_VEIN, + _CredentialType.kFace: CRED_TYPE_FACE, + _CredentialType.kAliroCredentialIssuerKey: "aliro_credential_issuer_key", + _CredentialType.kAliroEvictableEndpointKey: "aliro_evictable_endpoint_key", + _CredentialType.kAliroNonEvictableEndpointKey: "aliro_non_evictable_endpoint_key", +} + +# Credential rule mapping (Matter DoorLock CredentialRuleEnum) +_CredentialRule = clusters.DoorLock.Enums.CredentialRuleEnum +CREDENTIAL_RULE_MAP: dict[int, str] = { + _CredentialRule.kSingle: "single", + _CredentialRule.kDual: "dual", + _CredentialRule.kTri: "tri", +} +CREDENTIAL_RULE_REVERSE_MAP: dict[str, int] = { + v: k for k, v in CREDENTIAL_RULE_MAP.items() +} + +# Reverse mapping for credential types (str -> int) +CREDENTIAL_TYPE_REVERSE_MAP: dict[str, int] = { + v: k for k, v in CREDENTIAL_TYPE_MAP.items() +} + +# Credential types allowed in set/clear services (excludes programming_pin, aliro_*) +SERVICE_CREDENTIAL_TYPES = [ + CRED_TYPE_PIN, + CRED_TYPE_RFID, + CRED_TYPE_FINGERPRINT, + CRED_TYPE_FINGER_VEIN, + CRED_TYPE_FACE, +] diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index f0718dead21537..ca36aa5cee979d 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -124,8 +124,13 @@ def __init__( and ep.has_attribute(None, entity_info.primary_attribute) ): self._name_postfix = str(self._endpoint.endpoint_id) - if self._platform_translation_key and not self.translation_key: - self._attr_translation_key = self._platform_translation_key + # Always set translation_key for state_attributes translations. + # For primary entities (no postfix), suppress the translated name, + # so only the device name is used. + if self._platform_translation_key and not self.translation_key: + self._attr_translation_key = self._platform_translation_key + if not self._name_postfix: + self._attr_name = None # Matter labels can be used to modify the entity name # by appending the text. @@ -280,9 +285,9 @@ async def send_device_command( self, command: ClusterCommand, **kwargs: Any, - ) -> None: + ) -> Any: """Send device command on the primary attribute's endpoint.""" - await self.matter_client.send_device_command( + return await self.matter_client.send_device_command( node_id=self._endpoint.node.node_id, endpoint_id=self._endpoint.endpoint_id, command=command, diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index ec96875c06b446..be65b462108085 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -174,6 +174,27 @@ } }, "services": { + "clear_lock_credential": { + "service": "mdi:key-remove" + }, + "clear_lock_user": { + "service": "mdi:account-remove" + }, + "get_lock_credential_status": { + "service": "mdi:key-chain" + }, + "get_lock_info": { + "service": "mdi:lock-question" + }, + "get_lock_users": { + "service": "mdi:account-multiple" + }, + "set_lock_credential": { + "service": "mdi:key-plus" + }, + "set_lock_user": { + "service": "mdi:account-lock" + }, "water_heater_boost": { "service": "mdi:water-boiler" } diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index 6a8e262df17b20..599f34bc9f4ff0 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -47,6 +47,14 @@ clusters.ColorControl.Enums.ColorModeEnum.kColorTemperatureMireds: ColorMode.COLOR_TEMP, } +# Maximum Mireds value per the Matter spec is 65279 +# Conversion between Kelvin and Mireds is 1,000,000 / Kelvin, so this corresponds to a minimum color temperature of ~15.3K +# Which is shown in UI as 15 Kelvin due to rounding. +# But converting 15 Kelvin back to Mireds gives 66666 which is above the maximum, +# and causes Invoke error, so cap values over maximum when sending +MATTER_MAX_MIREDS = 65279 + + # there's a bug in (at least) Espressif's implementation of light transitions # on devices based on Matter 1.0. Mark potential devices with this issue. # https://github.com/home-assistant/core/issues/113775 @@ -152,7 +160,7 @@ async def _set_color_temp( ) await self.send_device_command( clusters.ColorControl.Commands.MoveToColorTemperature( - colorTemperatureMireds=color_temp_mired, + colorTemperatureMireds=min(color_temp_mired, MATTER_MAX_MIREDS), # transition in matter is measured in tenths of a second transitionTime=int(transition * 10), # allow setting the color while the light is off, diff --git a/homeassistant/components/matter/lock.py b/homeassistant/components/matter/lock.py index 330735f338b066..80316ea8014823 100644 --- a/homeassistant/components/matter/lock.py +++ b/homeassistant/components/matter/lock.py @@ -7,6 +7,7 @@ from typing import Any from chip.clusters import Objects as clusters +from matter_server.common.errors import MatterError from matter_server.common.models import EventType, MatterNodeEvent from homeassistant.components.lock import ( @@ -17,32 +18,56 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CODE, Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import LOGGER +from .const import ( + ATTR_CREDENTIAL_DATA, + ATTR_CREDENTIAL_INDEX, + ATTR_CREDENTIAL_RULE, + ATTR_CREDENTIAL_TYPE, + ATTR_USER_INDEX, + ATTR_USER_NAME, + ATTR_USER_STATUS, + ATTR_USER_TYPE, + LOCK_TIMED_REQUEST_TIMEOUT_MS, + LOGGER, +) from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter +from .lock_helpers import ( + DoorLockFeature, + GetLockCredentialStatusResult, + GetLockInfoResult, + GetLockUsersResult, + SetLockCredentialResult, + clear_lock_credential, + clear_lock_user, + get_lock_credential_status, + get_lock_info, + get_lock_users, + set_lock_credential, + set_lock_user, +) from .models import MatterDiscoverySchema -DOOR_LOCK_OPERATION_SOURCE = { - # mapping from operation source id's to textual representation - 0: "Unspecified", - 1: "Manual", # [Optional] - 2: "Proprietary Remote", # [Optional] - 3: "Keypad", # [Optional] - 4: "Auto", # [Optional] - 5: "Button", # [Optional] - 6: "Schedule", # [HDSCH] - 7: "Remote", # [M] - 8: "RFID", # [RID] - 9: "Biometric", # [USR] - 10: "Aliro", # [Aliro] +# Door lock operation source mapping (Matter DoorLock OperationSourceEnum) +_OperationSource = clusters.DoorLock.Enums.OperationSourceEnum +DOOR_LOCK_OPERATION_SOURCE: dict[int, str] = { + _OperationSource.kUnspecified: "Unspecified", + _OperationSource.kManual: "Manual", + _OperationSource.kProprietaryRemote: "Proprietary Remote", + _OperationSource.kKeypad: "Keypad", + _OperationSource.kAuto: "Auto", + _OperationSource.kButton: "Button", + _OperationSource.kSchedule: "Schedule", + _OperationSource.kRemote: "Remote", + _OperationSource.kRfid: "RFID", + _OperationSource.kBiometric: "Biometric", + _OperationSource.kAliro: "Aliro", } -DoorLockFeature = clusters.DoorLock.Bitmaps.Feature - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -98,17 +123,15 @@ def _on_matter_node_event( node_event.data, ) - # handle the DoorLock events + # Handle the DoorLock events node_event_data: dict[str, int] = node_event.data or {} match node_event.event_id: - case ( - clusters.DoorLock.Events.LockOperation.event_id - ): # Lock cluster event 2 - # update the changed_by attribute to indicate lock operation source + case clusters.DoorLock.Events.LockOperation.event_id: operation_source: int = node_event_data.get("operationSource", -1) - self._attr_changed_by = DOOR_LOCK_OPERATION_SOURCE.get( + source_name = DOOR_LOCK_OPERATION_SOURCE.get( operation_source, "Unknown" ) + self._attr_changed_by = source_name self.async_write_ha_state() @property @@ -146,7 +169,7 @@ async def async_lock(self, **kwargs: Any) -> None: code_bytes = code.encode() if code else None await self.send_device_command( command=clusters.DoorLock.Commands.LockDoor(code_bytes), - timed_request_timeout_ms=1000, + timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS, ) async def async_unlock(self, **kwargs: Any) -> None: @@ -168,12 +191,12 @@ async def async_unlock(self, **kwargs: Any) -> None: # and unlatch on the HA 'open' command. await self.send_device_command( command=clusters.DoorLock.Commands.UnboltDoor(code_bytes), - timed_request_timeout_ms=1000, + timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS, ) else: await self.send_device_command( command=clusters.DoorLock.Commands.UnlockDoor(code_bytes), - timed_request_timeout_ms=1000, + timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS, ) async def async_open(self, **kwargs: Any) -> None: @@ -190,7 +213,7 @@ async def async_open(self, **kwargs: Any) -> None: code_bytes = code.encode() if code else None await self.send_device_command( command=clusters.DoorLock.Commands.UnlockDoor(code_bytes), - timed_request_timeout_ms=1000, + timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS, ) @callback @@ -256,6 +279,109 @@ def _calculate_features( supported_features |= LockEntityFeature.OPEN self._attr_supported_features = supported_features + # --- Entity service methods --- + + async def async_set_lock_user(self, **kwargs: Any) -> None: + """Set a lock user (full CRUD).""" + try: + await set_lock_user( + self.matter_client, + self._endpoint.node, + user_index=kwargs.get(ATTR_USER_INDEX), + user_name=kwargs.get(ATTR_USER_NAME), + user_type=kwargs.get(ATTR_USER_TYPE), + credential_rule=kwargs.get(ATTR_CREDENTIAL_RULE), + ) + except MatterError as err: + raise HomeAssistantError( + f"Failed to set lock user on {self.entity_id}: {err}" + ) from err + + async def async_clear_lock_user(self, **kwargs: Any) -> None: + """Clear a lock user.""" + try: + await clear_lock_user( + self.matter_client, + self._endpoint.node, + kwargs[ATTR_USER_INDEX], + ) + except MatterError as err: + raise HomeAssistantError( + f"Failed to clear lock user on {self.entity_id}: {err}" + ) from err + + async def async_get_lock_info(self) -> GetLockInfoResult: + """Get lock capabilities and configuration info.""" + try: + return await get_lock_info( + self.matter_client, + self._endpoint.node, + ) + except MatterError as err: + raise HomeAssistantError( + f"Failed to get lock info for {self.entity_id}: {err}" + ) from err + + async def async_get_lock_users(self) -> GetLockUsersResult: + """Get all users from the lock.""" + try: + return await get_lock_users( + self.matter_client, + self._endpoint.node, + ) + except MatterError as err: + raise HomeAssistantError( + f"Failed to get lock users for {self.entity_id}: {err}" + ) from err + + async def async_set_lock_credential(self, **kwargs: Any) -> SetLockCredentialResult: + """Set a credential on the lock.""" + try: + return await set_lock_credential( + self.matter_client, + self._endpoint.node, + credential_type=kwargs[ATTR_CREDENTIAL_TYPE], + credential_data=kwargs[ATTR_CREDENTIAL_DATA], + credential_index=kwargs.get(ATTR_CREDENTIAL_INDEX), + user_index=kwargs.get(ATTR_USER_INDEX), + user_status=kwargs.get(ATTR_USER_STATUS), + user_type=kwargs.get(ATTR_USER_TYPE), + ) + except MatterError as err: + raise HomeAssistantError( + f"Failed to set lock credential on {self.entity_id}: {err}" + ) from err + + async def async_clear_lock_credential(self, **kwargs: Any) -> None: + """Clear a credential from the lock.""" + try: + await clear_lock_credential( + self.matter_client, + self._endpoint.node, + credential_type=kwargs[ATTR_CREDENTIAL_TYPE], + credential_index=kwargs[ATTR_CREDENTIAL_INDEX], + ) + except MatterError as err: + raise HomeAssistantError( + f"Failed to clear lock credential on {self.entity_id}: {err}" + ) from err + + async def async_get_lock_credential_status( + self, **kwargs: Any + ) -> GetLockCredentialStatusResult: + """Get the status of a credential slot on the lock.""" + try: + return await get_lock_credential_status( + self.matter_client, + self._endpoint.node, + credential_type=kwargs[ATTR_CREDENTIAL_TYPE], + credential_index=kwargs[ATTR_CREDENTIAL_INDEX], + ) + except MatterError as err: + raise HomeAssistantError( + f"Failed to get credential status for {self.entity_id}: {err}" + ) from err + DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( diff --git a/homeassistant/components/matter/lock_helpers.py b/homeassistant/components/matter/lock_helpers.py new file mode 100644 index 00000000000000..1f95aba19877a9 --- /dev/null +++ b/homeassistant/components/matter/lock_helpers.py @@ -0,0 +1,843 @@ +"""Lock-specific helpers for the Matter integration. + +Provides DoorLock cluster endpoint resolution, feature detection, and +business logic for lock user/credential management. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, TypedDict + +from chip.clusters import Objects as clusters +from chip.clusters.Types import NullValue + +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +from .const import ( + CRED_TYPE_FACE, + CRED_TYPE_FINGER_VEIN, + CRED_TYPE_FINGERPRINT, + CRED_TYPE_PIN, + CRED_TYPE_RFID, + CREDENTIAL_RULE_MAP, + CREDENTIAL_RULE_REVERSE_MAP, + CREDENTIAL_TYPE_MAP, + CREDENTIAL_TYPE_REVERSE_MAP, + LOCK_TIMED_REQUEST_TIMEOUT_MS, + USER_STATUS_MAP, + USER_STATUS_REVERSE_MAP, + USER_TYPE_MAP, + USER_TYPE_REVERSE_MAP, +) + +# Error translation keys (used in ServiceValidationError/HomeAssistantError) +ERR_CREDENTIAL_TYPE_NOT_SUPPORTED = "credential_type_not_supported" +ERR_INVALID_CREDENTIAL_DATA = "invalid_credential_data" + +# SetCredential response status mapping (Matter DlStatus) +_DlStatus = clusters.DoorLock.Enums.DlStatus +SET_CREDENTIAL_STATUS_MAP: dict[int, str] = { + _DlStatus.kSuccess: "success", + _DlStatus.kFailure: "failure", + _DlStatus.kDuplicate: "duplicate", + _DlStatus.kOccupied: "occupied", +} + +if TYPE_CHECKING: + from matter_server.client import MatterClient + from matter_server.client.models.node import MatterEndpoint, MatterNode + +# DoorLock Feature bitmap from Matter SDK +DoorLockFeature = clusters.DoorLock.Bitmaps.Feature + + +# --- TypedDicts for service action responses --- + + +class LockUserCredentialData(TypedDict): + """Credential data within a user response.""" + + type: str + index: int | None + + +class LockUserData(TypedDict): + """User data returned from lock queries.""" + + user_index: int | None + user_name: str | None + user_unique_id: int | None + user_status: str + user_type: str + credential_rule: str + credentials: list[LockUserCredentialData] + next_user_index: int | None + + +class SetLockUserResult(TypedDict): + """Result of set_lock_user service action.""" + + user_index: int + + +class GetLockUsersResult(TypedDict): + """Result of get_lock_users service action.""" + + max_users: int + users: list[LockUserData] + + +class GetLockInfoResult(TypedDict): + """Result of get_lock_info service action.""" + + supports_user_management: bool + supported_credential_types: list[str] + max_users: int | None + max_pin_users: int | None + max_rfid_users: int | None + max_credentials_per_user: int | None + min_pin_length: int | None + max_pin_length: int | None + min_rfid_length: int | None + max_rfid_length: int | None + + +class SetLockCredentialResult(TypedDict): + """Result of set_lock_credential service action.""" + + credential_index: int + user_index: int | None + next_credential_index: int | None + + +class GetLockCredentialStatusResult(TypedDict): + """Result of get_lock_credential_status service action.""" + + credential_exists: bool + user_index: int | None + next_credential_index: int | None + + +def _get_lock_endpoint_from_node(node: MatterNode) -> MatterEndpoint | None: + """Get the DoorLock endpoint from a node. + + Returns the first endpoint that has the DoorLock cluster, or None if not found. + """ + for endpoint in node.endpoints.values(): + if endpoint.has_cluster(clusters.DoorLock): + return endpoint + return None + + +def _get_feature_map(endpoint: MatterEndpoint) -> int | None: + """Read the DoorLock FeatureMap attribute from an endpoint.""" + value: int | None = endpoint.get_attribute_value( + None, clusters.DoorLock.Attributes.FeatureMap + ) + return value + + +def _lock_supports_usr_feature(endpoint: MatterEndpoint) -> bool: + """Check if lock endpoint supports USR (User) feature. + + The USR feature indicates the lock supports user and credential management + commands like SetUser, GetUser, SetCredential, etc. + """ + feature_map = _get_feature_map(endpoint) + if feature_map is None: + return False + return bool(feature_map & DoorLockFeature.kUser) + + +# --- Pure utility functions --- + + +def _get_attr(obj: Any, attr: str) -> Any: + """Get attribute from object or dict. + + Matter SDK responses can be either dataclass objects or dicts depending on + the SDK version and serialization context. NullValue (a truthy, + non-iterable singleton) is normalized to None. + """ + if isinstance(obj, dict): + value = obj.get(attr) + else: + value = getattr(obj, attr, None) + # The Matter SDK uses NullValue for nullable fields instead of None. + if value is NullValue: + return None + return value + + +def _get_supported_credential_types(feature_map: int) -> list[str]: + """Get list of supported credential types from feature map.""" + types = [] + if feature_map & DoorLockFeature.kPinCredential: + types.append(CRED_TYPE_PIN) + if feature_map & DoorLockFeature.kRfidCredential: + types.append(CRED_TYPE_RFID) + if feature_map & DoorLockFeature.kFingerCredentials: + types.append(CRED_TYPE_FINGERPRINT) + if feature_map & DoorLockFeature.kFaceCredentials: + types.append(CRED_TYPE_FACE) + return types + + +def _format_user_response(user_data: Any) -> LockUserData | None: + """Format GetUser response to API response format. + + Returns None if the user slot is empty (no userStatus). + """ + if user_data is None: + return None + + user_status = _get_attr(user_data, "userStatus") + if user_status is None: + return None + + creds = _get_attr(user_data, "credentials") + credentials: list[LockUserCredentialData] = [ + LockUserCredentialData( + type=CREDENTIAL_TYPE_MAP.get(_get_attr(cred, "credentialType"), "unknown"), + index=_get_attr(cred, "credentialIndex"), + ) + for cred in (creds or []) + ] + + return LockUserData( + user_index=_get_attr(user_data, "userIndex"), + user_name=_get_attr(user_data, "userName"), + user_unique_id=_get_attr(user_data, "userUniqueID"), + user_status=USER_STATUS_MAP.get(user_status, "unknown"), + user_type=USER_TYPE_MAP.get(_get_attr(user_data, "userType"), "unknown"), + credential_rule=CREDENTIAL_RULE_MAP.get( + _get_attr(user_data, "credentialRule"), "unknown" + ), + credentials=credentials, + next_user_index=_get_attr(user_data, "nextUserIndex"), + ) + + +# --- Credential management helpers --- + + +class LockEndpointNotFoundError(HomeAssistantError): + """Lock endpoint not found on node.""" + + +class UsrFeatureNotSupportedError(ServiceValidationError): + """Lock does not support USR (user management) feature.""" + + +class UserSlotEmptyError(ServiceValidationError): + """User slot is empty.""" + + +class NoAvailableUserSlotsError(ServiceValidationError): + """No available user slots on the lock.""" + + +class CredentialTypeNotSupportedError(ServiceValidationError): + """Lock does not support the requested credential type.""" + + +class CredentialDataInvalidError(ServiceValidationError): + """Credential data fails validation.""" + + +class SetCredentialFailedError(HomeAssistantError): + """SetCredential command returned a non-success status.""" + + +def _get_lock_endpoint_or_raise(node: MatterNode) -> MatterEndpoint: + """Get the DoorLock endpoint from a node or raise an error.""" + lock_endpoint = _get_lock_endpoint_from_node(node) + if lock_endpoint is None: + raise LockEndpointNotFoundError("No lock endpoint found on this device") + return lock_endpoint + + +def _ensure_usr_support(lock_endpoint: MatterEndpoint) -> None: + """Ensure the lock endpoint supports USR (user management) feature. + + Raises UsrFeatureNotSupportedError if the lock doesn't support user management. + """ + if not _lock_supports_usr_feature(lock_endpoint): + raise UsrFeatureNotSupportedError( + "Lock does not support user/credential management" + ) + + +# --- High-level business logic functions --- + + +async def get_lock_info( + matter_client: MatterClient, + node: MatterNode, +) -> GetLockInfoResult: + """Get lock capabilities and configuration info. + + Returns a typed dict with lock capability information. + Raises HomeAssistantError if lock endpoint not found. + """ + lock_endpoint = _get_lock_endpoint_or_raise(node) + supports_usr = _lock_supports_usr_feature(lock_endpoint) + + # Get feature map for credential type detection + feature_map = ( + lock_endpoint.get_attribute_value(None, clusters.DoorLock.Attributes.FeatureMap) + or 0 + ) + + result = GetLockInfoResult( + supports_user_management=supports_usr, + supported_credential_types=_get_supported_credential_types(feature_map), + max_users=None, + max_pin_users=None, + max_rfid_users=None, + max_credentials_per_user=None, + min_pin_length=None, + max_pin_length=None, + min_rfid_length=None, + max_rfid_length=None, + ) + + # Populate capacity info if USR feature is supported + if supports_usr: + result["max_users"] = lock_endpoint.get_attribute_value( + None, clusters.DoorLock.Attributes.NumberOfTotalUsersSupported + ) + result["max_pin_users"] = lock_endpoint.get_attribute_value( + None, clusters.DoorLock.Attributes.NumberOfPINUsersSupported + ) + result["max_rfid_users"] = lock_endpoint.get_attribute_value( + None, clusters.DoorLock.Attributes.NumberOfRFIDUsersSupported + ) + result["max_credentials_per_user"] = lock_endpoint.get_attribute_value( + None, clusters.DoorLock.Attributes.NumberOfCredentialsSupportedPerUser + ) + result["min_pin_length"] = lock_endpoint.get_attribute_value( + None, clusters.DoorLock.Attributes.MinPINCodeLength + ) + result["max_pin_length"] = lock_endpoint.get_attribute_value( + None, clusters.DoorLock.Attributes.MaxPINCodeLength + ) + result["min_rfid_length"] = lock_endpoint.get_attribute_value( + None, clusters.DoorLock.Attributes.MinRFIDCodeLength + ) + result["max_rfid_length"] = lock_endpoint.get_attribute_value( + None, clusters.DoorLock.Attributes.MaxRFIDCodeLength + ) + + return result + + +async def set_lock_user( + matter_client: MatterClient, + node: MatterNode, + *, + user_index: int | None = None, + user_name: str | None = None, + user_unique_id: int | None = None, + user_status: str | None = None, + user_type: str | None = None, + credential_rule: str | None = None, +) -> SetLockUserResult: + """Add or update a user on the lock. + + When user_status, user_type, or credential_rule is None, defaults are used + for new users and existing values are preserved for modifications. + + Returns typed dict with user_index on success. + Raises HomeAssistantError on failure. + """ + lock_endpoint = _get_lock_endpoint_or_raise(node) + _ensure_usr_support(lock_endpoint) + + if user_index is None: + # Adding new user - find first available slot + max_users = ( + lock_endpoint.get_attribute_value( + None, clusters.DoorLock.Attributes.NumberOfTotalUsersSupported + ) + or 0 + ) + + for idx in range(1, max_users + 1): + get_user_response = await matter_client.send_device_command( + node_id=node.node_id, + endpoint_id=lock_endpoint.endpoint_id, + command=clusters.DoorLock.Commands.GetUser(userIndex=idx), + ) + if _get_attr(get_user_response, "userStatus") is None: + user_index = idx + break + + if user_index is None: + raise NoAvailableUserSlotsError("No available user slots on the lock") + + user_status_enum = ( + USER_STATUS_REVERSE_MAP.get( + user_status, + clusters.DoorLock.Enums.UserStatusEnum.kOccupiedEnabled, + ) + if user_status is not None + else clusters.DoorLock.Enums.UserStatusEnum.kOccupiedEnabled + ) + + await matter_client.send_device_command( + node_id=node.node_id, + endpoint_id=lock_endpoint.endpoint_id, + command=clusters.DoorLock.Commands.SetUser( + operationType=clusters.DoorLock.Enums.DataOperationTypeEnum.kAdd, + userIndex=user_index, + userName=user_name, + userUniqueID=user_unique_id, + userStatus=user_status_enum, + userType=USER_TYPE_REVERSE_MAP.get( + user_type, + clusters.DoorLock.Enums.UserTypeEnum.kUnrestrictedUser, + ) + if user_type is not None + else clusters.DoorLock.Enums.UserTypeEnum.kUnrestrictedUser, + credentialRule=CREDENTIAL_RULE_REVERSE_MAP.get( + credential_rule, + clusters.DoorLock.Enums.CredentialRuleEnum.kSingle, + ) + if credential_rule is not None + else clusters.DoorLock.Enums.CredentialRuleEnum.kSingle, + ), + timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS, + ) + else: + # Updating existing user - preserve existing values when not specified + get_user_response = await matter_client.send_device_command( + node_id=node.node_id, + endpoint_id=lock_endpoint.endpoint_id, + command=clusters.DoorLock.Commands.GetUser(userIndex=user_index), + ) + + if _get_attr(get_user_response, "userStatus") is None: + raise UserSlotEmptyError(f"User slot {user_index} is empty") + + resolved_user_name = ( + user_name + if user_name is not None + else _get_attr(get_user_response, "userName") + ) + resolved_unique_id = ( + user_unique_id + if user_unique_id is not None + else _get_attr(get_user_response, "userUniqueID") + ) + + resolved_status = ( + USER_STATUS_REVERSE_MAP[user_status] + if user_status is not None + else _get_attr(get_user_response, "userStatus") + ) + + resolved_type = ( + USER_TYPE_REVERSE_MAP[user_type] + if user_type is not None + else _get_attr(get_user_response, "userType") + ) + + resolved_rule = ( + CREDENTIAL_RULE_REVERSE_MAP[credential_rule] + if credential_rule is not None + else _get_attr(get_user_response, "credentialRule") + ) + + await matter_client.send_device_command( + node_id=node.node_id, + endpoint_id=lock_endpoint.endpoint_id, + command=clusters.DoorLock.Commands.SetUser( + operationType=clusters.DoorLock.Enums.DataOperationTypeEnum.kModify, + userIndex=user_index, + userName=resolved_user_name, + userUniqueID=resolved_unique_id, + userStatus=resolved_status, + userType=resolved_type, + credentialRule=resolved_rule, + ), + timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS, + ) + + return SetLockUserResult(user_index=user_index) + + +async def get_lock_users( + matter_client: MatterClient, + node: MatterNode, +) -> GetLockUsersResult: + """Get all users from the lock. + + Returns typed dict with users list and max_users capacity. + Raises HomeAssistantError on failure. + """ + lock_endpoint = _get_lock_endpoint_or_raise(node) + _ensure_usr_support(lock_endpoint) + + max_users = ( + lock_endpoint.get_attribute_value( + None, clusters.DoorLock.Attributes.NumberOfTotalUsersSupported + ) + or 0 + ) + + users: list[LockUserData] = [] + current_index = 1 + + # Iterate through users using next_user_index for efficiency + while current_index is not None and current_index <= max_users: + get_user_response = await matter_client.send_device_command( + node_id=node.node_id, + endpoint_id=lock_endpoint.endpoint_id, + command=clusters.DoorLock.Commands.GetUser( + userIndex=current_index, + ), + ) + + user_data = _format_user_response(get_user_response) + if user_data is not None: + users.append(user_data) + + # Move to next user index + next_index = _get_attr(get_user_response, "nextUserIndex") + if next_index is None or next_index <= current_index: + break + current_index = next_index + + return GetLockUsersResult( + max_users=max_users, + users=users, + ) + + +async def clear_lock_user( + matter_client: MatterClient, + node: MatterNode, + user_index: int, +) -> None: + """Clear a user from the lock. + + Per the Matter spec, ClearUser also clears all associated credentials + and schedules for the user. + Use index 0xFFFE (CLEAR_ALL_INDEX) to clear all users. + Raises HomeAssistantError on failure. + """ + lock_endpoint = _get_lock_endpoint_or_raise(node) + _ensure_usr_support(lock_endpoint) + + await matter_client.send_device_command( + node_id=node.node_id, + endpoint_id=lock_endpoint.endpoint_id, + command=clusters.DoorLock.Commands.ClearUser( + userIndex=user_index, + ), + timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS, + ) + + +# --- Credential validation helpers --- + +# Map credential type strings to the feature bit that must be set +_CREDENTIAL_TYPE_FEATURE_MAP: dict[str, int] = { + CRED_TYPE_PIN: DoorLockFeature.kPinCredential, + CRED_TYPE_RFID: DoorLockFeature.kRfidCredential, + CRED_TYPE_FINGERPRINT: DoorLockFeature.kFingerCredentials, + CRED_TYPE_FINGER_VEIN: DoorLockFeature.kFingerCredentials, + CRED_TYPE_FACE: DoorLockFeature.kFaceCredentials, +} + +# Map credential type strings to the capacity attribute for slot iteration. +# Biometric types have no dedicated capacity attribute; fall back to total users. +_CREDENTIAL_TYPE_CAPACITY_ATTR = { + CRED_TYPE_PIN: clusters.DoorLock.Attributes.NumberOfPINUsersSupported, + CRED_TYPE_RFID: clusters.DoorLock.Attributes.NumberOfRFIDUsersSupported, +} + + +def _validate_credential_type_support( + lock_endpoint: MatterEndpoint, credential_type: str +) -> None: + """Validate the lock supports the requested credential type. + + Raises CredentialTypeNotSupportedError if not supported. + """ + required_bit = _CREDENTIAL_TYPE_FEATURE_MAP.get(credential_type) + if required_bit is None: + raise CredentialTypeNotSupportedError( + translation_domain="matter", + translation_key=ERR_CREDENTIAL_TYPE_NOT_SUPPORTED, + translation_placeholders={"credential_type": credential_type}, + ) + + feature_map = _get_feature_map(lock_endpoint) or 0 + if not (feature_map & required_bit): + raise CredentialTypeNotSupportedError( + translation_domain="matter", + translation_key=ERR_CREDENTIAL_TYPE_NOT_SUPPORTED, + translation_placeholders={"credential_type": credential_type}, + ) + + +def _validate_credential_data( + lock_endpoint: MatterEndpoint, credential_type: str, credential_data: str +) -> None: + """Validate credential data against lock constraints. + + For PIN: checks digits-only and length against Min/MaxPINCodeLength. + For RFID: checks valid hex and byte length against Min/MaxRFIDCodeLength. + Raises CredentialDataInvalidError on failure. + """ + if credential_type == CRED_TYPE_PIN: + if not credential_data.isdigit(): + raise CredentialDataInvalidError( + translation_domain="matter", + translation_key=ERR_INVALID_CREDENTIAL_DATA, + translation_placeholders={"reason": "PIN must contain only digits"}, + ) + min_len = ( + lock_endpoint.get_attribute_value( + None, clusters.DoorLock.Attributes.MinPINCodeLength + ) + or 0 + ) + max_len = ( + lock_endpoint.get_attribute_value( + None, clusters.DoorLock.Attributes.MaxPINCodeLength + ) + or 255 + ) + if not min_len <= len(credential_data) <= max_len: + raise CredentialDataInvalidError( + translation_domain="matter", + translation_key=ERR_INVALID_CREDENTIAL_DATA, + translation_placeholders={ + "reason": (f"PIN length must be between {min_len} and {max_len}") + }, + ) + + elif credential_type == CRED_TYPE_RFID: + try: + rfid_bytes = bytes.fromhex(credential_data) + except ValueError as err: + raise CredentialDataInvalidError( + translation_domain="matter", + translation_key=ERR_INVALID_CREDENTIAL_DATA, + translation_placeholders={ + "reason": "RFID data must be valid hexadecimal" + }, + ) from err + min_len = ( + lock_endpoint.get_attribute_value( + None, clusters.DoorLock.Attributes.MinRFIDCodeLength + ) + or 0 + ) + max_len = ( + lock_endpoint.get_attribute_value( + None, clusters.DoorLock.Attributes.MaxRFIDCodeLength + ) + or 255 + ) + if not min_len <= len(rfid_bytes) <= max_len: + raise CredentialDataInvalidError( + translation_domain="matter", + translation_key=ERR_INVALID_CREDENTIAL_DATA, + translation_placeholders={ + "reason": ( + f"RFID data length must be between" + f" {min_len} and {max_len} bytes" + ) + }, + ) + + +def _credential_data_to_bytes(credential_type: str, credential_data: str) -> bytes: + """Convert credential data string to bytes for the Matter command.""" + if credential_type == CRED_TYPE_RFID: + return bytes.fromhex(credential_data) + # PIN and other types: encode as UTF-8 + return credential_data.encode() + + +# --- Credential business logic functions --- + + +async def set_lock_credential( + matter_client: MatterClient, + node: MatterNode, + *, + credential_type: str, + credential_data: str, + credential_index: int | None = None, + user_index: int | None = None, + user_status: str | None = None, + user_type: str | None = None, +) -> SetLockCredentialResult: + """Add or modify a credential on the lock. + + Returns typed dict with credential_index, user_index, and next_credential_index. + Raises ServiceValidationError for validation failures. + Raises HomeAssistantError for device communication failures. + """ + lock_endpoint = _get_lock_endpoint_or_raise(node) + _ensure_usr_support(lock_endpoint) + _validate_credential_type_support(lock_endpoint, credential_type) + _validate_credential_data(lock_endpoint, credential_type, credential_data) + + cred_type_int = CREDENTIAL_TYPE_REVERSE_MAP[credential_type] + cred_data_bytes = _credential_data_to_bytes(credential_type, credential_data) + + # Determine operation type and credential index + operation_type = clusters.DoorLock.Enums.DataOperationTypeEnum.kAdd + + if credential_index is None: + # Auto-find first available credential slot. + # Use the credential-type-specific capacity as the upper bound. + max_creds_attr = _CREDENTIAL_TYPE_CAPACITY_ATTR.get( + credential_type, + clusters.DoorLock.Attributes.NumberOfTotalUsersSupported, + ) + max_creds_raw = lock_endpoint.get_attribute_value(None, max_creds_attr) + max_creds = ( + max_creds_raw if isinstance(max_creds_raw, int) and max_creds_raw > 0 else 5 + ) + for idx in range(1, max_creds + 1): + status_response = await matter_client.send_device_command( + node_id=node.node_id, + endpoint_id=lock_endpoint.endpoint_id, + command=clusters.DoorLock.Commands.GetCredentialStatus( + credential=clusters.DoorLock.Structs.CredentialStruct( + credentialType=cred_type_int, + credentialIndex=idx, + ), + ), + ) + if not _get_attr(status_response, "credentialExists"): + credential_index = idx + break + + if credential_index is None: + raise NoAvailableUserSlotsError("No available credential slots on the lock") + else: + # Check if slot is occupied to determine Add vs Modify + status_response = await matter_client.send_device_command( + node_id=node.node_id, + endpoint_id=lock_endpoint.endpoint_id, + command=clusters.DoorLock.Commands.GetCredentialStatus( + credential=clusters.DoorLock.Structs.CredentialStruct( + credentialType=cred_type_int, + credentialIndex=credential_index, + ), + ), + ) + if _get_attr(status_response, "credentialExists"): + operation_type = clusters.DoorLock.Enums.DataOperationTypeEnum.kModify + + # Resolve optional user_status and user_type enums + resolved_user_status = ( + USER_STATUS_REVERSE_MAP.get(user_status) if user_status is not None else None + ) + resolved_user_type = ( + USER_TYPE_REVERSE_MAP.get(user_type) if user_type is not None else None + ) + + set_cred_response = await matter_client.send_device_command( + node_id=node.node_id, + endpoint_id=lock_endpoint.endpoint_id, + command=clusters.DoorLock.Commands.SetCredential( + operationType=operation_type, + credential=clusters.DoorLock.Structs.CredentialStruct( + credentialType=cred_type_int, + credentialIndex=credential_index, + ), + credentialData=cred_data_bytes, + userIndex=user_index, + userStatus=resolved_user_status, + userType=resolved_user_type, + ), + timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS, + ) + + status_code = _get_attr(set_cred_response, "status") + status_str = SET_CREDENTIAL_STATUS_MAP.get(status_code, f"unknown({status_code})") + if status_str != "success": + raise SetCredentialFailedError( + translation_domain="matter", + translation_key="set_credential_failed", + translation_placeholders={"status": status_str}, + ) + + return SetLockCredentialResult( + credential_index=credential_index, + user_index=_get_attr(set_cred_response, "userIndex"), + next_credential_index=_get_attr(set_cred_response, "nextCredentialIndex"), + ) + + +async def clear_lock_credential( + matter_client: MatterClient, + node: MatterNode, + *, + credential_type: str, + credential_index: int, +) -> None: + """Clear a credential from the lock. + + Raises HomeAssistantError on failure. + """ + lock_endpoint = _get_lock_endpoint_or_raise(node) + _ensure_usr_support(lock_endpoint) + + cred_type_int = CREDENTIAL_TYPE_REVERSE_MAP[credential_type] + + await matter_client.send_device_command( + node_id=node.node_id, + endpoint_id=lock_endpoint.endpoint_id, + command=clusters.DoorLock.Commands.ClearCredential( + credential=clusters.DoorLock.Structs.CredentialStruct( + credentialType=cred_type_int, + credentialIndex=credential_index, + ), + ), + timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS, + ) + + +async def get_lock_credential_status( + matter_client: MatterClient, + node: MatterNode, + *, + credential_type: str, + credential_index: int, +) -> GetLockCredentialStatusResult: + """Get the status of a credential slot on the lock. + + Returns typed dict with credential_exists, user_index, next_credential_index. + Raises HomeAssistantError on failure. + """ + lock_endpoint = _get_lock_endpoint_or_raise(node) + _ensure_usr_support(lock_endpoint) + + cred_type_int = CREDENTIAL_TYPE_REVERSE_MAP[credential_type] + + response = await matter_client.send_device_command( + node_id=node.node_id, + endpoint_id=lock_endpoint.endpoint_id, + command=clusters.DoorLock.Commands.GetCredentialStatus( + credential=clusters.DoorLock.Structs.CredentialStruct( + credentialType=cred_type_int, + credentialIndex=credential_index, + ), + ), + ) + + return GetLockCredentialStatusResult( + credential_exists=bool(_get_attr(response, "credentialExists")), + user_index=_get_attr(response, "userIndex"), + next_credential_index=_get_attr(response, "nextCredentialIndex"), + ) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index d353d11707498c..8274886cd11942 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": ["python-matter-server==8.1.2"], + "requirements": ["matter-python-client==0.4.1"], "zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."] } diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index 3820c303126195..91b5fd05c4b390 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -187,6 +187,27 @@ def _update_from_device(self) -> None: # allow None value to account for 'default' value allow_none_value=True, ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterNumberEntityDescription( + key="power_on_level", + entity_category=EntityCategory.CONFIG, + translation_key="power_on_level", + native_max_value=255, + native_min_value=0, + mode=NumberMode.BOX, + # use 255 to indicate that the value should revert to the default + device_to_ha=lambda x: 255 if x is None else x, + ha_to_device=lambda x: None if x == 255 else int(x), + native_step=1, + native_unit_of_measurement=None, + ), + entity_class=MatterNumber, + required_attributes=(clusters.LevelControl.Attributes.StartUpCurrentLevel,), + not_device_type=(device_types.Speaker,), + # allow None value to account for 'default' value + allow_none_value=True, + ), MatterDiscoverySchema( platform=Platform.NUMBER, entity_description=MatterNumberEntityDescription( @@ -498,6 +519,7 @@ def _update_from_device(self) -> None: required_attributes=( custom_clusters.InovelliCluster.Attributes.LEDIndicatorIntensityOff, ), + product_id=(2, 16), ), MatterDiscoverySchema( platform=Platform.NUMBER, @@ -514,6 +536,7 @@ def _update_from_device(self) -> None: required_attributes=( custom_clusters.InovelliCluster.Attributes.LEDIndicatorIntensityOn, ), + product_id=(2, 16), ), MatterDiscoverySchema( platform=Platform.NUMBER, diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 1f9d2742325c72..6a0273e05bba08 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -722,8 +722,8 @@ def _update_from_device(self) -> None: platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( key="NitrogenDioxideSensor", - translation_key="nitrogen_dioxide", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.NITROGEN_DIOXIDE, state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, @@ -908,6 +908,7 @@ def _update_from_device(self) -> None: required_attributes=( clusters.ElectricalPowerMeasurement.Attributes.ApparentPower, ), + allow_none_value=True, ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -924,6 +925,7 @@ def _update_from_device(self) -> None: required_attributes=( clusters.ElectricalPowerMeasurement.Attributes.ReactivePower, ), + allow_none_value=True, ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -939,6 +941,7 @@ def _update_from_device(self) -> None: ), entity_class=MatterSensor, required_attributes=(clusters.ElectricalPowerMeasurement.Attributes.Voltage,), + allow_none_value=True, ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -956,6 +959,7 @@ def _update_from_device(self) -> None: required_attributes=( clusters.ElectricalPowerMeasurement.Attributes.RMSVoltage, ), + allow_none_value=True, ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -973,6 +977,7 @@ def _update_from_device(self) -> None: required_attributes=( clusters.ElectricalPowerMeasurement.Attributes.ApparentCurrent, ), + allow_none_value=True, ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -990,6 +995,7 @@ def _update_from_device(self) -> None: required_attributes=( clusters.ElectricalPowerMeasurement.Attributes.ActiveCurrent, ), + allow_none_value=True, ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -1007,6 +1013,7 @@ def _update_from_device(self) -> None: required_attributes=( clusters.ElectricalPowerMeasurement.Attributes.ReactiveCurrent, ), + allow_none_value=True, ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -1024,6 +1031,7 @@ def _update_from_device(self) -> None: required_attributes=( clusters.ElectricalPowerMeasurement.Attributes.RMSCurrent, ), + allow_none_value=True, ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -1039,6 +1047,7 @@ def _update_from_device(self) -> None: device_to_ha=lambda x: x.energy, ), entity_class=MatterSensor, + allow_none_value=True, required_attributes=( clusters.ElectricalEnergyMeasurement.Attributes.CumulativeEnergyImported, ), @@ -1058,6 +1067,7 @@ def _update_from_device(self) -> None: device_to_ha=lambda x: x.energy, ), entity_class=MatterSensor, + allow_none_value=True, required_attributes=( clusters.ElectricalEnergyMeasurement.Attributes.CumulativeEnergyExported, ), diff --git a/homeassistant/components/matter/services.py b/homeassistant/components/matter/services.py index 62a2da51a967e0..e8076d76cfc1c1 100644 --- a/homeassistant/components/matter/services.py +++ b/homeassistant/components/matter/services.py @@ -4,11 +4,27 @@ import voluptuous as vol +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, SupportsResponse, callback from homeassistant.helpers import config_validation as cv, service -from .const import DOMAIN +from .const import ( + ATTR_CREDENTIAL_DATA, + ATTR_CREDENTIAL_INDEX, + ATTR_CREDENTIAL_RULE, + ATTR_CREDENTIAL_TYPE, + ATTR_USER_INDEX, + ATTR_USER_NAME, + ATTR_USER_STATUS, + ATTR_USER_TYPE, + CLEAR_ALL_INDEX, + CREDENTIAL_RULE_REVERSE_MAP, + CREDENTIAL_TYPE_REVERSE_MAP, + DOMAIN, + SERVICE_CREDENTIAL_TYPES, + USER_TYPE_REVERSE_MAP, +) ATTR_DURATION = "duration" ATTR_EMERGENCY_BOOST = "emergency_boost" @@ -36,3 +52,108 @@ def async_setup_services(hass: HomeAssistant) -> None: }, func="async_set_boost", ) + + # Lock services - Full user CRUD + service.async_register_platform_entity_service( + hass, + DOMAIN, + "set_lock_user", + entity_domain=LOCK_DOMAIN, + schema={ + vol.Optional(ATTR_USER_INDEX): vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Optional(ATTR_USER_NAME): vol.Any(str, None), + vol.Optional(ATTR_USER_TYPE): vol.In(USER_TYPE_REVERSE_MAP.keys()), + vol.Optional(ATTR_CREDENTIAL_RULE): vol.In( + CREDENTIAL_RULE_REVERSE_MAP.keys() + ), + }, + func="async_set_lock_user", + ) + + service.async_register_platform_entity_service( + hass, + DOMAIN, + "clear_lock_user", + entity_domain=LOCK_DOMAIN, + schema={ + vol.Required(ATTR_USER_INDEX): vol.All( + vol.Coerce(int), + vol.Any(vol.Range(min=1), CLEAR_ALL_INDEX), + ), + }, + func="async_clear_lock_user", + ) + + # Lock services - Query operations + service.async_register_platform_entity_service( + hass, + DOMAIN, + "get_lock_info", + entity_domain=LOCK_DOMAIN, + schema={}, + func="async_get_lock_info", + supports_response=SupportsResponse.ONLY, + ) + + service.async_register_platform_entity_service( + hass, + DOMAIN, + "get_lock_users", + entity_domain=LOCK_DOMAIN, + schema={}, + func="async_get_lock_users", + supports_response=SupportsResponse.ONLY, + ) + + # Lock services - Credential management + service.async_register_platform_entity_service( + hass, + DOMAIN, + "set_lock_credential", + entity_domain=LOCK_DOMAIN, + schema={ + vol.Required(ATTR_CREDENTIAL_TYPE): vol.In(SERVICE_CREDENTIAL_TYPES), + vol.Required(ATTR_CREDENTIAL_DATA): str, + vol.Optional(ATTR_CREDENTIAL_INDEX): vol.All( + vol.Coerce(int), vol.Range(min=0) + ), + vol.Optional(ATTR_USER_INDEX): vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Optional(ATTR_USER_STATUS): vol.In( + ["occupied_enabled", "occupied_disabled"] + ), + vol.Optional(ATTR_USER_TYPE): vol.In(USER_TYPE_REVERSE_MAP.keys()), + }, + func="async_set_lock_credential", + supports_response=SupportsResponse.ONLY, + ) + + service.async_register_platform_entity_service( + hass, + DOMAIN, + "clear_lock_credential", + entity_domain=LOCK_DOMAIN, + schema={ + vol.Required(ATTR_CREDENTIAL_TYPE): vol.In(SERVICE_CREDENTIAL_TYPES), + vol.Required(ATTR_CREDENTIAL_INDEX): vol.All( + vol.Coerce(int), vol.Range(min=0) + ), + }, + func="async_clear_lock_credential", + ) + + service.async_register_platform_entity_service( + hass, + DOMAIN, + "get_lock_credential_status", + entity_domain=LOCK_DOMAIN, + schema={ + vol.Required(ATTR_CREDENTIAL_TYPE): vol.In( + CREDENTIAL_TYPE_REVERSE_MAP.keys() + ), + vol.Required(ATTR_CREDENTIAL_INDEX): vol.All( + vol.Coerce(int), vol.Range(min=0) + ), + }, + func="async_get_lock_credential_status", + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/matter/services.yaml b/homeassistant/components/matter/services.yaml index a0f8b9d7862acd..5127c7b602f3b6 100644 --- a/homeassistant/components/matter/services.yaml +++ b/homeassistant/components/matter/services.yaml @@ -1,3 +1,177 @@ +clear_lock_credential: + target: + entity: + domain: lock + integration: matter + fields: + credential_type: + selector: + select: + options: + - pin + - rfid + - fingerprint + - finger_vein + - face + required: true + credential_index: + selector: + number: + min: 0 + max: 65534 + step: 1 + mode: box + required: true + +clear_lock_user: + target: + entity: + domain: lock + integration: matter + fields: + user_index: + selector: + number: + min: 1 + max: 65534 + step: 1 + mode: box + required: true + +get_lock_credential_status: + target: + entity: + domain: lock + integration: matter + fields: + credential_type: + selector: + select: + options: + - programming_pin + - pin + - rfid + - fingerprint + - finger_vein + - face + - aliro_credential_issuer_key + - aliro_evictable_endpoint_key + - aliro_non_evictable_endpoint_key + required: true + credential_index: + selector: + number: + min: 0 + max: 65534 + step: 1 + mode: box + required: true + +get_lock_info: + target: + entity: + domain: lock + integration: matter + +get_lock_users: + target: + entity: + domain: lock + integration: matter + +set_lock_credential: + target: + entity: + domain: lock + integration: matter + fields: + credential_type: + selector: + select: + options: + - pin + - rfid + - fingerprint + - finger_vein + - face + required: true + credential_data: + selector: + text: + required: true + credential_index: + selector: + number: + min: 0 + max: 65534 + step: 1 + mode: box + user_index: + selector: + number: + min: 1 + max: 65534 + step: 1 + mode: box + user_status: + selector: + select: + options: + - occupied_enabled + - occupied_disabled + user_type: + selector: + select: + options: + - unrestricted_user + - year_day_schedule_user + - week_day_schedule_user + - programming_user + - non_access_user + - forced_user + - disposable_user + - expiring_user + - schedule_restricted_user + - remote_only_user + +set_lock_user: + target: + entity: + domain: lock + integration: matter + fields: + user_index: + selector: + number: + min: 1 + max: 255 + step: 1 + mode: box + user_name: + selector: + text: + user_type: + selector: + select: + options: + - unrestricted_user + - year_day_schedule_user + - week_day_schedule_user + - programming_user + - non_access_user + - forced_user + - disposable_user + - expiring_user + - schedule_restricted_user + - remote_only_user + credential_rule: + selector: + select: + options: + - single + - dual + - tri + water_heater_boost: target: entity: diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 8aaa64c2390507..b8db87c58b8ecc 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -1,14 +1,14 @@ { "config": { "abort": { - "addon_get_discovery_info_failed": "Failed to get Matter Server add-on discovery info.", - "addon_info_failed": "Failed to get Matter Server add-on info.", - "addon_install_failed": "Failed to install the Matter Server add-on.", - "addon_start_failed": "Failed to start the Matter Server add-on.", + "addon_get_discovery_info_failed": "Failed to get Matter Server app discovery info.", + "addon_info_failed": "Failed to get Matter Server app info.", + "addon_install_failed": "Failed to install the Matter Server app.", + "addon_start_failed": "Failed to start the Matter Server app.", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "not_matter_addon": "Discovered add-on is not the official Matter Server add-on.", + "not_matter_addon": "Discovered app is not the official Matter Server app.", "reconfiguration_successful": "Successfully reconfigured the Matter integration." }, "error": { @@ -18,15 +18,15 @@ }, "flow_title": "{name}", "progress": { - "install_addon": "Please wait while the Matter Server add-on installation finishes. This can take several minutes.", - "start_addon": "Please wait while the Matter Server add-on starts. This add-on is what powers Matter in Home Assistant. This may take some seconds." + "install_addon": "Please wait while the Matter Server app installation finishes. This can take several minutes.", + "start_addon": "Please wait while the Matter Server app starts. This app is what powers Matter in Home Assistant. This may take some seconds." }, "step": { "hassio_confirm": { - "title": "Set up the Matter integration with the Matter Server add-on" + "title": "Set up the Matter integration with the Matter Server app" }, "install_addon": { - "title": "The add-on installation has started" + "title": "The app installation has started" }, "manual": { "data": { @@ -35,13 +35,13 @@ }, "on_supervisor": { "data": { - "use_addon": "Use the official Matter Server Supervisor add-on" + "use_addon": "Use the official Matter Server Supervisor app" }, - "description": "Do you want to use the official Matter Server Supervisor add-on?\n\nIf you are already running the Matter Server in another add-on, in a custom container, natively etc., then do not select this option.", + "description": "Do you want to use the official Matter Server Supervisor app?\n\nIf you are already running the Matter Server in another app, in a custom container, natively etc., then do not select this option.", "title": "Select connection method" }, "start_addon": { - "title": "Starting add-on." + "title": "Starting app." } } }, @@ -238,6 +238,9 @@ "on_transition_time": { "name": "On transition time" }, + "power_on_level": { + "name": "Power-on level" + }, "pump_setpoint": { "name": "Setpoint" }, @@ -322,11 +325,11 @@ } }, "startup_on_off": { - "name": "Power-on behavior on startup", + "name": "Power-on behavior", "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]", - "previous": "Previous", + "previous": "Previous state", "toggle": "[%key:common::action::toggle%]" } }, @@ -619,6 +622,17 @@ } } }, + "exceptions": { + "credential_type_not_supported": { + "message": "The lock does not support credential type `{credential_type}`." + }, + "invalid_credential_data": { + "message": "Invalid credential data: {reason}." + }, + "set_credential_failed": { + "message": "Failed to set credential: lock returned status `{status}`." + } + }, "issues": { "server_version_version_too_new": { "description": "The version of the Matter Server you are currently running is too new for this version of Home Assistant. Please update Home Assistant or downgrade the Matter Server to an older version to fix this issue.", @@ -630,6 +644,52 @@ } }, "services": { + "clear_lock_credential": { + "description": "Removes a credential from a lock.", + "fields": { + "credential_index": { + "description": "The credential slot index to clear.", + "name": "Credential index" + }, + "credential_type": { + "description": "The type of credential to clear.", + "name": "Credential type" + } + }, + "name": "Clear lock credential" + }, + "clear_lock_user": { + "description": "Deletes a lock user and all associated credentials. Use index 65534 to clear all users.", + "fields": { + "user_index": { + "description": "The user slot index (1-based) to clear, or 65534 to clear all.", + "name": "User index" + } + }, + "name": "Clear lock user" + }, + "get_lock_credential_status": { + "description": "Returns the status of a credential slot on a lock.", + "fields": { + "credential_index": { + "description": "The credential slot index to query.", + "name": "Credential index" + }, + "credential_type": { + "description": "The type of credential to query.", + "name": "Credential type" + } + }, + "name": "Get lock credential status" + }, + "get_lock_info": { + "description": "Returns lock capabilities including supported credential types, user capacity, and PIN length constraints.", + "name": "Get lock info" + }, + "get_lock_users": { + "description": "Returns all users configured on a lock with their credentials.", + "name": "Get lock users" + }, "open_commissioning_window": { "description": "Allows adding one of your devices to another Matter network by opening the commissioning window for this Matter device for 60 seconds.", "fields": { @@ -640,6 +700,58 @@ }, "name": "Open commissioning window" }, + "set_lock_credential": { + "description": "Adds or updates a credential on a lock.", + "fields": { + "credential_data": { + "description": "The credential data. For PIN: digits only. For RFID: hexadecimal string.", + "name": "Credential data" + }, + "credential_index": { + "description": "The credential slot index. Leave empty to auto-find an available slot.", + "name": "Credential index" + }, + "credential_type": { + "description": "The type of credential (e.g., pin, rfid, fingerprint).", + "name": "Credential type" + }, + "user_index": { + "description": "The user index to associate the credential with. Leave empty for automatic assignment.", + "name": "User index" + }, + "user_status": { + "description": "The user status to set when creating a new user for this credential.", + "name": "User status" + }, + "user_type": { + "description": "The user type to set when creating a new user for this credential.", + "name": "User type" + } + }, + "name": "Set lock credential" + }, + "set_lock_user": { + "description": "Creates or updates a lock user.", + "fields": { + "credential_rule": { + "description": "The credential rule for the user.", + "name": "Credential rule" + }, + "user_index": { + "description": "The user slot index (1-based). Leave empty to auto-find an available slot.", + "name": "User index" + }, + "user_name": { + "description": "The name for the user.", + "name": "User name" + }, + "user_type": { + "description": "The type of user to create.", + "name": "User type" + } + }, + "name": "Set lock user" + }, "water_heater_boost": { "description": "Enables water heater boost for a specific duration.", "fields": { diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index ee906662de50a7..7c125763703b49 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -46,30 +46,42 @@ async def async_setup_entry( class MatterSwitchEntityDescription(SwitchEntityDescription, MatterEntityDescription): """Describe Matter Switch entities.""" + inverted: bool = False + class MatterSwitch(MatterEntity, SwitchEntity): """Representation of a Matter switch.""" + entity_description: MatterSwitchEntityDescription _platform_translation_key = "switch" + def _get_command_for_value(self, value: bool) -> ClusterCommand: + """Get the appropriate command for the desired value. + + Applies inversion if needed (e.g., for inverted logic like mute). + """ + send_value = not value if self.entity_description.inverted else value + return ( + clusters.OnOff.Commands.On() + if send_value + else clusters.OnOff.Commands.Off() + ) + async def async_turn_on(self, **kwargs: Any) -> None: """Turn switch on.""" - await self.send_device_command( - clusters.OnOff.Commands.On(), - ) + await self.send_device_command(self._get_command_for_value(True)) async def async_turn_off(self, **kwargs: Any) -> None: """Turn switch off.""" - await self.send_device_command( - clusters.OnOff.Commands.Off(), - ) + await self.send_device_command(self._get_command_for_value(False)) @callback def _update_from_device(self) -> None: """Update from device.""" - self._attr_is_on = self.get_matter_attribute_value( - self._entity_info.primary_attribute - ) + value = self.get_matter_attribute_value(self._entity_info.primary_attribute) + if self.entity_description.inverted: + value = not value + self._attr_is_on = value class MatterGenericCommandSwitch(MatterSwitch): @@ -121,9 +133,7 @@ async def send_device_command( @dataclass(frozen=True, kw_only=True) -class MatterGenericCommandSwitchEntityDescription( - SwitchEntityDescription, MatterEntityDescription -): +class MatterGenericCommandSwitchEntityDescription(MatterSwitchEntityDescription): """Describe Matter Generic command Switch entities.""" # command: a custom callback to create the command to send to the device @@ -133,9 +143,7 @@ class MatterGenericCommandSwitchEntityDescription( @dataclass(frozen=True, kw_only=True) -class MatterNumericSwitchEntityDescription( - SwitchEntityDescription, MatterEntityDescription -): +class MatterNumericSwitchEntityDescription(MatterSwitchEntityDescription): """Describe Matter Numeric Switch entities.""" @@ -146,11 +154,10 @@ class MatterNumericSwitch(MatterSwitch): async def _async_set_native_value(self, value: bool) -> None: """Update the current value.""" + send_value: Any = value if value_convert := self.entity_description.ha_to_device: send_value = value_convert(value) - await self.write_attribute( - value=send_value, - ) + await self.write_attribute(value=send_value) async def async_turn_on(self, **kwargs: Any) -> None: """Turn switch on.""" @@ -248,19 +255,12 @@ def _update_from_device(self) -> None: ), MatterDiscoverySchema( platform=Platform.SWITCH, - entity_description=MatterNumericSwitchEntityDescription( + entity_description=MatterSwitchEntityDescription( key="MatterMuteToggle", translation_key="speaker_mute", - device_to_ha={ - True: False, # True means volume is on, so HA should show mute as off - False: True, # False means volume is off (muted), so HA should show mute as on - }.get, - ha_to_device={ - False: True, # HA showing mute as off means volume is on, so send True - True: False, # HA showing mute as on means volume is off (muted), so send False - }.get, + inverted=True, ), - entity_class=MatterNumericSwitch, + entity_class=MatterSwitch, required_attributes=(clusters.OnOff.Attributes.OnOff,), device_type=(device_types.Speaker,), ), diff --git a/homeassistant/components/matter/update.py b/homeassistant/components/matter/update.py index 26a8da72e550d8..56d98f8b5b0fcc 100644 --- a/homeassistant/components/matter/update.py +++ b/homeassistant/components/matter/update.py @@ -80,6 +80,7 @@ class MatterUpdate(MatterEntity, UpdateEntity): # Matter server. _attr_should_poll = True _software_update: MatterSoftwareVersion | None = None + _installed_software_version: int | None = None _cancel_update: CALLBACK_TYPE | None = None _attr_supported_features = ( UpdateEntityFeature.INSTALL @@ -92,6 +93,9 @@ class MatterUpdate(MatterEntity, UpdateEntity): def _update_from_device(self) -> None: """Update from device.""" + self._installed_software_version = self.get_matter_attribute_value( + clusters.BasicInformation.Attributes.SoftwareVersion + ) self._attr_installed_version = self.get_matter_attribute_value( clusters.BasicInformation.Attributes.SoftwareVersionString ) @@ -123,6 +127,22 @@ def _update_from_device(self) -> None: else: self._attr_update_percentage = None + def _format_latest_version( + self, update_information: MatterSoftwareVersion + ) -> str | None: + """Return the version string to expose in Home Assistant.""" + latest_version = update_information.software_version_string + if self._installed_software_version is None: + return latest_version + + if update_information.software_version == self._installed_software_version: + return self._attr_installed_version or latest_version + + if latest_version == self._attr_installed_version: + return f"{latest_version} ({update_information.software_version})" + + return latest_version + async def async_update(self) -> None: """Call when the entity needs to be updated.""" try: @@ -130,11 +150,13 @@ async def async_update(self) -> None: node_id=self._endpoint.node.node_id ) if not update_information: + self._software_update = None self._attr_latest_version = self._attr_installed_version + self._attr_release_url = None return self._software_update = update_information - self._attr_latest_version = update_information.software_version_string + self._attr_latest_version = self._format_latest_version(update_information) self._attr_release_url = update_information.release_notes_url except UpdateCheckError as err: @@ -212,7 +234,12 @@ async def async_install( software_version: str | int | None = version if self._software_update is not None and ( - version is None or version == self._software_update.software_version_string + version is None + or version + in { + self._software_update.software_version_string, + self._attr_latest_version, + } ): # Update to the version previously fetched and shown. # We can pass the integer version directly to speedup download. diff --git a/homeassistant/components/matter/vacuum.py b/homeassistant/components/matter/vacuum.py index 93922fde0f6f35..30fa8a7fde37fd 100644 --- a/homeassistant/components/matter/vacuum.py +++ b/homeassistant/components/matter/vacuum.py @@ -4,12 +4,14 @@ from dataclasses import dataclass from enum import IntEnum +import logging from typing import TYPE_CHECKING, Any from chip.clusters import Objects as clusters from matter_server.client.models import device_types from homeassistant.components.vacuum import ( + Segment, StateVacuumEntity, StateVacuumEntityDescription, VacuumActivity, @@ -25,6 +27,8 @@ from .helpers import get_matter from .models import MatterDiscoverySchema +_LOGGER = logging.getLogger(__name__) + class OperationalState(IntEnum): """Operational State of the vacuum cleaner. @@ -70,6 +74,7 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): """Representation of a Matter Vacuum cleaner entity.""" _last_accepted_commands: list[int] | None = None + _last_service_area_feature_map: int | None = None _supported_run_modes: ( dict[int, clusters.RvcRunMode.Structs.ModeOptionStruct] | None ) = None @@ -136,6 +141,16 @@ async def async_start(self) -> None: "No supported run mode found to start the vacuum cleaner." ) + # Reset selected areas to an unconstrained selection to ensure start + # performs a full clean and does not reuse a previous area-targeted + # selection. + if VacuumEntityFeature.CLEAN_AREA in self.supported_features: + # Matter ServiceArea: an empty NewAreas list means unconstrained + # operation (full clean). + await self.send_device_command( + clusters.ServiceArea.Commands.SelectAreas(newAreas=[]) + ) + await self.send_device_command( clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode) ) @@ -144,6 +159,68 @@ async def async_pause(self) -> None: """Pause the cleaning task.""" await self.send_device_command(clusters.RvcOperationalState.Commands.Pause()) + @property + def _current_segments(self) -> dict[str, Segment]: + """Return the current cleanable segments reported by the device.""" + supported_areas: list[clusters.ServiceArea.Structs.AreaStruct] = ( + self.get_matter_attribute_value( + clusters.ServiceArea.Attributes.SupportedAreas + ) + ) + + segments: dict[str, Segment] = {} + for area in supported_areas: + area_name = None + location_info = area.areaInfo.locationInfo + if location_info not in (None, clusters.NullValue): + area_name = location_info.locationName + + if area_name: + segment_id = str(area.areaID) + segments[segment_id] = Segment(id=segment_id, name=area_name) + + return segments + + async def async_get_segments(self) -> list[Segment]: + """Get the segments that can be cleaned. + + Returns a list of segments containing their ids and names. + """ + return list(self._current_segments.values()) + + async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None: + """Clean the specified segments. + + Args: + segment_ids: List of segment IDs to clean. + **kwargs: Additional arguments (unused). + + """ + area_ids = [int(segment_id) for segment_id in segment_ids] + + mode = self._get_run_mode_by_tag(ModeTag.CLEANING) + if mode is None: + raise HomeAssistantError( + "No supported run mode found to start the vacuum cleaner." + ) + + response = await self.send_device_command( + clusters.ServiceArea.Commands.SelectAreas(newAreas=area_ids) + ) + + if ( + response + and response["status"] + != clusters.ServiceArea.Enums.SelectAreasStatus.kSuccess + ): + raise HomeAssistantError( + f"Failed to select areas: {response['statusText'] or response['status']}" + ) + + await self.send_device_command( + clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode) + ) + @callback def _update_from_device(self) -> None: """Update from device.""" @@ -176,16 +253,43 @@ def _update_from_device(self) -> None: state = VacuumActivity.CLEANING self._attr_activity = state + if ( + VacuumEntityFeature.CLEAN_AREA in self.supported_features + and self.registry_entry is not None + and (last_seen_segments := self.last_seen_segments) is not None + # Ignore empty segments; some devices transiently + # report an empty list before sending the real one. + and (current_segments := self._current_segments) + ): + last_seen_by_id = {s.id: s for s in last_seen_segments} + if current_segments != last_seen_by_id: + _LOGGER.debug( + "Vacuum segments changed: last_seen=%s, current=%s", + last_seen_by_id, + current_segments, + ) + self.async_create_segments_issue() + @callback def _calculate_features(self) -> None: """Calculate features for HA Vacuum platform.""" accepted_operational_commands: list[int] = self.get_matter_attribute_value( clusters.RvcOperationalState.Attributes.AcceptedCommandList ) - # in principle the feature set should not change, except for the accepted commands - if self._last_accepted_commands == accepted_operational_commands: + service_area_feature_map: int | None = self.get_matter_attribute_value( + clusters.ServiceArea.Attributes.FeatureMap + ) + + # In principle the feature set should not change, except for accepted + # commands and service area feature map. + if ( + self._last_accepted_commands == accepted_operational_commands + and self._last_service_area_feature_map == service_area_feature_map + ): return + self._last_accepted_commands = accepted_operational_commands + self._last_service_area_feature_map = service_area_feature_map supported_features: VacuumEntityFeature = VacuumEntityFeature(0) supported_features |= VacuumEntityFeature.START supported_features |= VacuumEntityFeature.STATE @@ -212,6 +316,12 @@ def _calculate_features(self) -> None: in accepted_operational_commands ): supported_features |= VacuumEntityFeature.RETURN_HOME + # Check if Map feature is enabled for clean area support + if ( + service_area_feature_map is not None + and service_area_feature_map & clusters.ServiceArea.Bitmaps.Feature.kMaps + ): + supported_features |= VacuumEntityFeature.CLEAN_AREA self._attr_supported_features = supported_features @@ -228,6 +338,10 @@ def _calculate_features(self) -> None: clusters.RvcRunMode.Attributes.CurrentMode, clusters.RvcOperationalState.Attributes.OperationalState, ), + optional_attributes=( + clusters.ServiceArea.Attributes.FeatureMap, + clusters.ServiceArea.Attributes.SupportedAreas, + ), device_type=(device_types.RoboticVacuumCleaner,), allow_none_value=True, ), diff --git a/homeassistant/components/matter/valve.py b/homeassistant/components/matter/valve.py index ce9f16921de47c..f2deea97d7fc22 100644 --- a/homeassistant/components/matter/valve.py +++ b/homeassistant/components/matter/valve.py @@ -69,34 +69,37 @@ async def async_set_valve_position(self, position: int) -> None: def _update_from_device(self) -> None: """Update from device.""" self._calculate_features() - current_state: int + self._attr_is_opening = False + self._attr_is_closing = False + + current_state: int | None current_state = self.get_matter_attribute_value( ValveConfigurationAndControl.Attributes.CurrentState ) - target_state: int + target_state: int | None target_state = self.get_matter_attribute_value( ValveConfigurationAndControl.Attributes.TargetState ) - if ( - current_state == ValveStateEnum.kTransitioning - and target_state == ValveStateEnum.kOpen + + if current_state is None: + self._attr_is_closed = None + elif current_state == ValveStateEnum.kTransitioning and ( + target_state == ValveStateEnum.kOpen ): self._attr_is_opening = True - self._attr_is_closing = False - elif ( - current_state == ValveStateEnum.kTransitioning - and target_state == ValveStateEnum.kClosed + self._attr_is_closed = None + elif current_state == ValveStateEnum.kTransitioning and ( + target_state == ValveStateEnum.kClosed ): - self._attr_is_opening = False self._attr_is_closing = True + self._attr_is_closed = None elif current_state == ValveStateEnum.kClosed: - self._attr_is_opening = False - self._attr_is_closing = False self._attr_is_closed = True - else: - self._attr_is_opening = False - self._attr_is_closing = False + elif current_state == ValveStateEnum.kOpen: self._attr_is_closed = False + else: + self._attr_is_closed = None + # handle optional position if self.supported_features & ValveEntityFeature.SET_POSITION: self._attr_current_valve_position = self.get_matter_attribute_value( @@ -145,6 +148,7 @@ def _calculate_features( ValveConfigurationAndControl.Attributes.CurrentState, ValveConfigurationAndControl.Attributes.TargetState, ), + allow_none_value=True, optional_attributes=(ValveConfigurationAndControl.Attributes.CurrentLevel,), device_type=(device_types.WaterValve,), ), diff --git a/homeassistant/components/maxcube/binary_sensor.py b/homeassistant/components/maxcube/binary_sensor.py index 208b93eb19ab93..a45404b7959090 100644 --- a/homeassistant/components/maxcube/binary_sensor.py +++ b/homeassistant/components/maxcube/binary_sensor.py @@ -61,7 +61,7 @@ def __init__(self, handler, device): self._attr_unique_id = self._device.serial @property - def is_on(self): + def is_on(self) -> bool: """Return true if the binary sensor is on/open.""" return self._device.is_open @@ -79,6 +79,6 @@ def __init__(self, handler, device): self._attr_unique_id = f"{self._device.serial}_battery" @property - def is_on(self): + def is_on(self) -> bool: """Return true if the binary sensor is on/open.""" return self._device.battery == 1 diff --git a/homeassistant/components/maxcube/climate.py b/homeassistant/components/maxcube/climate.py index 862e811fb6f251..c434d1463235fb 100644 --- a/homeassistant/components/maxcube/climate.py +++ b/homeassistant/components/maxcube/climate.py @@ -67,12 +67,21 @@ class MaxCubeClimate(ClimateEntity): """MAX! Cube ClimateEntity.""" _attr_hvac_modes = [HVACMode.OFF, HVACMode.AUTO, HVACMode.HEAT] + _attr_preset_modes = [ + PRESET_NONE, + PRESET_BOOST, + PRESET_COMFORT, + PRESET_ECO, + PRESET_AWAY, + PRESET_ON, + ] _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) + _attr_temperature_unit = UnitOfTemperature.CELSIUS def __init__(self, handler, device): """Initialize MAX! Cube ClimateEntity.""" @@ -80,17 +89,7 @@ def __init__(self, handler, device): self._attr_name = f"{room.name} {device.name}" self._cubehandle = handler self._device = device - self._attr_should_poll = True self._attr_unique_id = self._device.serial - self._attr_temperature_unit = UnitOfTemperature.CELSIUS - self._attr_preset_modes = [ - PRESET_NONE, - PRESET_BOOST, - PRESET_COMFORT, - PRESET_ECO, - PRESET_AWAY, - PRESET_ON, - ] @property def min_temp(self) -> float: @@ -106,7 +105,7 @@ def max_temp(self) -> float: return self._device.max_temperature or MAX_TEMPERATURE @property - def current_temperature(self): + def current_temperature(self) -> float: """Return the current temperature.""" return self._device.actual_temperature @@ -176,7 +175,7 @@ def hvac_action(self) -> HVACAction | None: return HVACAction.OFF if self.hvac_mode == HVACMode.OFF else HVACAction.IDLE @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" temp = self._device.target_temperature if temp is None or temp < self.min_temp or temp > self.max_temp: @@ -225,7 +224,7 @@ def set_preset_mode(self, preset_mode: str) -> None: raise ValueError(f"unsupported preset mode {preset_mode}") @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the optional state attributes.""" if not self._device.is_thermostat(): return {} diff --git a/homeassistant/components/mcp/config_flow.py b/homeassistant/components/mcp/config_flow.py index 8ade969bf9f6ac..2f93ffbd9603b2 100644 --- a/homeassistant/components/mcp/config_flow.py +++ b/homeassistant/components/mcp/config_flow.py @@ -2,9 +2,11 @@ from __future__ import annotations -from collections.abc import Mapping +import asyncio +from collections.abc import Iterable, Mapping from dataclasses import dataclass import logging +import re from typing import Any, cast import httpx @@ -41,6 +43,48 @@ } ) +# Headers and regex for WWW-Authenticate parsing for rfc9728 +WWW_AUTHENTICATE_HEADER = "WWW-Authenticate" +RESOURCE_METADATA_REGEXP = r'resource_metadata="([^"]+)"' +OAUTH_PROTECTED_RESOURCE_ENDPOINT = "/.well-known/oauth-protected-resource" +SCOPES_REGEXP = r'scope="([^"]+)"' + + +@dataclass +class AuthenticateHeader: + """Class to hold info from the WWW-Authenticate header for supporting rfc9728.""" + + resource_metadata_url: str + scopes: list[str] | None = None + + @classmethod + def from_header( + cls, url: str, error_response: httpx.Response + ) -> AuthenticateHeader | None: + """Create AuthenticateHeader from WWW-Authenticate header.""" + if not (header := error_response.headers.get(WWW_AUTHENTICATE_HEADER)) or not ( + match := re.search(RESOURCE_METADATA_REGEXP, header) + ): + return None + resource_metadata_url = str(URL(url).join(URL(match.group(1)))) + scope_match = re.search(SCOPES_REGEXP, header) + return cls( + resource_metadata_url=resource_metadata_url, + scopes=scope_match.group(1).split(" ") if scope_match else None, + ) + + +@dataclass +class ResourceMetadata: + """Class to hold protected resource metadata defined in rfc9728.""" + + authorization_servers: list[str] + """List of authorization server URLs.""" + + supported_scopes: list[str] | None = None + """List of supported scopes.""" + + # OAuth server discovery endpoint for rfc8414 OAUTH_DISCOVERY_ENDPOINT = ".well-known/oauth-authorization-server" MCP_DISCOVERY_HEADERS = { @@ -58,40 +102,27 @@ class OAuthConfig: scopes: list[str] | None = None -async def async_discover_oauth_config( - hass: HomeAssistant, mcp_server_url: str +async def async_discover_authorization_server( + hass: HomeAssistant, auth_server_url: str ) -> OAuthConfig: - """Discover the OAuth configuration for the MCP server. - - This implements the functionality in the MCP spec for discovery. If the MCP server URL - is https://api.example.com/v1/mcp, then: - - The authorization base URL is https://api.example.com - - The metadata endpoint MUST be at https://api.example.com/.well-known/oauth-authorization-server - - For servers that do not implement OAuth 2.0 Authorization Server Metadata, the client uses - default paths relative to the authorization base URL. - """ - parsed_url = URL(mcp_server_url) - discovery_endpoint = str(parsed_url.with_path(OAUTH_DISCOVERY_ENDPOINT)) + """Perform OAuth 2.0 Authorization Server Metadata discovery as per RFC8414.""" + parsed_url = URL(auth_server_url) + urls_to_try = [ + str(parsed_url.with_path(path)) + for path in _authorization_server_discovery_paths(parsed_url) + ] + # Pick any successful response and propagate exceptions except for + # 404 where we fall back to assuming some default paths. try: - async with httpx.AsyncClient(headers=MCP_DISCOVERY_HEADERS) as client: - response = await client.get(discovery_endpoint) - response.raise_for_status() - except httpx.TimeoutException as error: - _LOGGER.info("Timeout connecting to MCP server: %s", error) - raise TimeoutConnectError from error - except httpx.HTTPStatusError as error: - if error.response.status_code == 404: - _LOGGER.info("Authorization Server Metadata not found, using default paths") - return OAuthConfig( - authorization_server=AuthorizationServer( - authorize_url=str(parsed_url.with_path("/authorize")), - token_url=str(parsed_url.with_path("/token")), - ) + response = await _async_fetch_any(hass, urls_to_try) + except NotFoundError: + _LOGGER.info("Authorization Server Metadata not found, using default paths") + return OAuthConfig( + authorization_server=AuthorizationServer( + authorize_url=str(parsed_url.with_path("/authorize")), + token_url=str(parsed_url.with_path("/token")), ) - raise CannotConnect from error - except httpx.HTTPError as error: - _LOGGER.info("Cannot discover OAuth configuration: %s", error) - raise CannotConnect from error + ) data = response.json() authorize_url = data["authorization_endpoint"] @@ -130,7 +161,8 @@ async def validate_input( except httpx.HTTPStatusError as error: _LOGGER.info("Cannot connect to MCP server: %s", error) if error.response.status_code == 401: - raise InvalidAuth from error + auth_header = AuthenticateHeader.from_header(url, error.response) + raise InvalidAuth(auth_header) from error raise CannotConnect from error except httpx.HTTPError as error: _LOGGER.info("Cannot connect to MCP server: %s", error) @@ -156,6 +188,7 @@ def __init__(self) -> None: super().__init__() self.data: dict[str, Any] = {} self.oauth_config: OAuthConfig | None = None + self.auth_header: AuthenticateHeader | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -171,7 +204,8 @@ async def async_step_user( errors["base"] = "timeout_connect" except CannotConnect: errors["base"] = "cannot_connect" - except InvalidAuth: + except InvalidAuth as err: + self.auth_header = err.metadata self.data[CONF_URL] = user_input[CONF_URL] return await self.async_step_auth_discovery() except MissingCapabilities: @@ -196,12 +230,34 @@ async def async_step_auth_discovery( """Handle the OAuth server discovery step. Since this OAuth server requires authentication, this step will attempt - to find the OAuth medata then run the OAuth authentication flow. + to find the OAuth metadata then run the OAuth authentication flow. """ + resource_metadata: ResourceMetadata | None = None try: - oauth_config = await async_discover_oauth_config( - self.hass, self.data[CONF_URL] - ) + if self.auth_header: + _LOGGER.debug( + "Resource metadata discovery from header: %s", self.auth_header + ) + resource_metadata = await async_discover_protected_resource( + self.hass, + self.auth_header.resource_metadata_url, + self.data[CONF_URL], + ) + _LOGGER.debug("Protected resource metadata: %s", resource_metadata) + oauth_config = await async_discover_authorization_server( + self.hass, + # Use the first authorization server from the resource metadata as it + # is the most common to have only one and there is not a defined strategy. + resource_metadata.authorization_servers[0], + ) + else: + _LOGGER.debug( + "Discovering authorization server without protected resource metadata" + ) + oauth_config = await async_discover_authorization_server( + self.hass, + self.data[CONF_URL], + ) except TimeoutConnectError: return self.async_abort(reason="timeout_connect") except CannotConnect: @@ -216,7 +272,9 @@ async def async_step_auth_discovery( { CONF_AUTHORIZATION_URL: oauth_config.authorization_server.authorize_url, CONF_TOKEN_URL: oauth_config.authorization_server.token_url, - CONF_SCOPE: oauth_config.scopes, + CONF_SCOPE: _select_scopes( + self.auth_header, oauth_config, resource_metadata + ), } ) return await self.async_step_credentials_choice() @@ -326,6 +384,143 @@ async def async_step_reauth_confirm( return await self.async_step_auth() +async def _async_fetch_any( + hass: HomeAssistant, + urls: Iterable[str], +) -> httpx.Response: + """Fetch all URLs concurrently and return the first successful response.""" + + async def fetch(url: str) -> httpx.Response: + _LOGGER.debug("Fetching URL %s", url) + try: + async with httpx.AsyncClient() as client: + response = await client.get(url) + response.raise_for_status() + return response + except httpx.TimeoutException as error: + _LOGGER.debug("Timeout fetching URL %s: %s", url, error) + raise TimeoutConnectError from error + except httpx.HTTPStatusError as error: + _LOGGER.debug("Server error for URL %s: %s", url, error) + if error.response.status_code == 404: + raise NotFoundError from error + raise CannotConnect from error + except httpx.HTTPError as error: + _LOGGER.debug("Cannot fetch URL %s: %s", url, error) + raise CannotConnect from error + + tasks = [asyncio.create_task(fetch(url)) for url in urls] + return_err: Exception | None = None + try: + for future in asyncio.as_completed(tasks): + try: + return await future + except Exception as err: # noqa: BLE001 + _LOGGER.debug("Fetch failed: %s", err) + if return_err is None: + return_err = err + continue + finally: + for task in tasks: + task.cancel() + + raise return_err or CannotConnect("No responses received from any URL") + + +async def async_discover_protected_resource( + hass: HomeAssistant, + auth_url: str, + mcp_server_url: str, +) -> ResourceMetadata: + """Discover the OAuth configuration for a protected resource for MCP spec version 2025-11-25+. + + This implements the functionality in the MCP spec for discovery. We use the information + from the WWW-Authenticate header to fetch the resource metadata implementing + RFC9728. + + For the url https://example.com/public/mcp we attempt these urls: + - https://example.com/.well-known/oauth-protected-resource/public/mcp + - https://example.com/.well-known/oauth-protected-resource + """ + parsed_url = URL(mcp_server_url) + urls_to_try = { + auth_url, + str( + parsed_url.with_path( + f"{OAUTH_PROTECTED_RESOURCE_ENDPOINT}{parsed_url.path}" + ) + ), + str(parsed_url.with_path(OAUTH_PROTECTED_RESOURCE_ENDPOINT)), + } + + response = await _async_fetch_any(hass, list(urls_to_try)) + + # Parse the OAuth Authorization Protected Resource Metadata (rfc9728). We + # expect to find at least one authorization server in the response and + # a valid resource field that matches the MCP server URL. + data = response.json() + if ( + not (authorization_servers := data.get("authorization_servers")) + or not (resource := data.get("resource")) + or (resource != mcp_server_url) + ): + _LOGGER.error("Invalid OAuth resource metadata: %s", data) + raise CannotConnect("OAuth resource metadata is invalid") + return ResourceMetadata( + authorization_servers=authorization_servers, + supported_scopes=data.get("scopes_supported"), + ) + + +def _authorization_server_discovery_paths(auth_server_url: URL) -> list[str]: + """Return the list of paths to try for OAuth server discovery. + + For an auth server url with path components, e.g., https://auth.example.com/tenant1 + clients try endpoints in the following priority order: + - OAuth 2.0 Authorization Server Metadata with path insertion: + https://auth.example.com/.well-known/oauth-authorization-server/tenant1 + - OpenID Connect Discovery 1.0 with path insertion: + https://auth.example.com/.well-known/openid-configuration/tenant1 + - OpenID Connect Discovery 1.0 path appending: + https://auth.example.com/tenant1/.well-known/openid-configuration + + For an auth server url without path components, e.g., https://auth.example.com + clients try: + - OAuth 2.0 Authorization Server Metadata: + https://auth.example.com/.well-known/oauth-authorization-server + - OpenID Connect Discovery 1.0: + https://auth.example.com/.well-known/openid-configuration + """ + if auth_server_url.path and auth_server_url.path != "/": + return [ + f"/.well-known/oauth-authorization-server{auth_server_url.path}", + f"/.well-known/openid-configuration{auth_server_url.path}", + f"{auth_server_url.path}/.well-known/openid-configuration", + ] + return [ + "/.well-known/oauth-authorization-server", + "/.well-known/openid-configuration", + ] + + +def _select_scopes( + auth_header: AuthenticateHeader | None, + oauth_config: OAuthConfig, + resource_metadata: ResourceMetadata | None, +) -> list[str] | None: + """Select OAuth scopes based on the MCP spec scope selection strategy. + + This follows the MCP spec strategy of preferring first the authenticate header, + then the protected resource metadata, then finally the default scopes from + the OAuth discovery. + """ + if auth_header and auth_header.scopes: + return auth_header.scopes + if resource_metadata and resource_metadata.supported_scopes: + return resource_metadata.supported_scopes + return oauth_config.scopes + + class InvalidUrl(HomeAssistantError): """Error to indicate the URL format is invalid.""" @@ -338,9 +533,18 @@ class TimeoutConnectError(HomeAssistantError): """Error to indicate we cannot connect.""" +class NotFoundError(CannotConnect): + """Error to indicate the resource was not found.""" + + class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" + def __init__(self, metadata: AuthenticateHeader | None = None) -> None: + """Initialize the error.""" + super().__init__() + self.metadata = metadata + class MissingCapabilities(HomeAssistantError): """Error to indicate that the MCP server is missing required capabilities.""" diff --git a/homeassistant/components/mealie/icons.json b/homeassistant/components/mealie/icons.json index 6a2afcdba3b72f..c7bc5e0772e88b 100644 --- a/homeassistant/components/mealie/icons.json +++ b/homeassistant/components/mealie/icons.json @@ -33,6 +33,9 @@ "get_recipes": { "service": "mdi:book-open-page-variant" }, + "get_shopping_list_items": { + "service": "mdi:basket" + }, "import_recipe": { "service": "mdi:map-search" }, diff --git a/homeassistant/components/mealie/manifest.json b/homeassistant/components/mealie/manifest.json index 0c53a302ebe0e1..6f9e61fd0fd893 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.1"] + "requirements": ["aiomealie==1.2.2"] } diff --git a/homeassistant/components/mealie/services.py b/homeassistant/components/mealie/services.py index f6ba4fea1b775b..d1e4745bf5988d 100644 --- a/homeassistant/components/mealie/services.py +++ b/homeassistant/components/mealie/services.py @@ -12,6 +12,7 @@ from awesomeversion import AwesomeVersion import voluptuous as vol +from homeassistant.components.todo import DOMAIN as TODO_DOMAIN from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_DATE from homeassistant.core import ( HomeAssistant, @@ -64,6 +65,8 @@ } ) +SERVICE_GET_SHOPPING_LIST_ITEMS = "get_shopping_list_items" + SERVICE_IMPORT_RECIPE = "import_recipe" SERVICE_IMPORT_RECIPE_SCHEMA = vol.Schema( { @@ -321,3 +324,12 @@ def async_setup_services(hass: HomeAssistant) -> None: schema=SERVICE_SET_MEALPLAN_SCHEMA, supports_response=SupportsResponse.OPTIONAL, ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_GET_SHOPPING_LIST_ITEMS, + entity_domain=TODO_DOMAIN, + schema=None, + func="async_get_shopping_list_items", + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/mealie/services.yaml b/homeassistant/components/mealie/services.yaml index 31181c0d0917e6..6eef192dfabfe1 100644 --- a/homeassistant/components/mealie/services.yaml +++ b/homeassistant/components/mealie/services.yaml @@ -45,6 +45,12 @@ get_recipes: mode: box unit_of_measurement: recipes +get_shopping_list_items: + target: + entity: + integration: mealie + domain: todo + import_recipe: fields: config_entry_id: diff --git a/homeassistant/components/mealie/strings.json b/homeassistant/components/mealie/strings.json index c3b6dfd6992fea..2c337dee445d5e 100644 --- a/homeassistant/components/mealie/strings.json +++ b/homeassistant/components/mealie/strings.json @@ -147,6 +147,9 @@ "setup_failed": { "message": "Could not connect to the Mealie instance." }, + "shopping_list_not_found": { + "message": "Shopping list with name or ID `{shopping_list}` not found." + }, "update_failed_mealplan": { "message": "Could not fetch mealplan data." }, @@ -227,6 +230,10 @@ }, "name": "Get recipes" }, + "get_shopping_list_items": { + "description": "Gets items from a shopping list in Mealie", + "name": "Get shopping list items" + }, "import_recipe": { "description": "Imports a recipe from an URL", "fields": { diff --git a/homeassistant/components/mealie/todo.py b/homeassistant/components/mealie/todo.py index c701af2865cdf4..c504ba1e7f0595 100644 --- a/homeassistant/components/mealie/todo.py +++ b/homeassistant/components/mealie/todo.py @@ -2,7 +2,15 @@ from __future__ import annotations -from aiomealie import MealieError, MutateShoppingItem, ShoppingItem, ShoppingList +from dataclasses import asdict + +from aiomealie import ( + MealieConnectionError, + MealieError, + MutateShoppingItem, + ShoppingItem, + ShoppingList, +) from homeassistant.components.todo import ( DOMAIN as TODO_DOMAIN, @@ -11,7 +19,7 @@ TodoListEntity, TodoListEntityFeature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceResponse from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -265,3 +273,18 @@ async def async_move_todo_item( def available(self) -> bool: """Return False if shopping list no longer available.""" return super().available and self._shopping_list_id in self.coordinator.data + + async def async_get_shopping_list_items(self) -> ServiceResponse: + """Get structured shopping list items.""" + client = self.coordinator.client + try: + shopping_items = await client.get_shopping_items(self._shopping_list_id) + except MealieConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from err + return { + "name": self.shopping_list.name, + "items": [asdict(item) for item in shopping_items.items], + } diff --git a/homeassistant/components/medcom_ble/__init__.py b/homeassistant/components/medcom_ble/__init__.py index 5c508688b54427..60f945f5adbb42 100644 --- a/homeassistant/components/medcom_ble/__init__.py +++ b/homeassistant/components/medcom_ble/__init__.py @@ -3,19 +3,17 @@ from __future__ import annotations from homeassistant.components import bluetooth -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 .coordinator import MedcomBleUpdateCoordinator +from .coordinator import MedcomBleConfigEntry, MedcomBleUpdateCoordinator # Supported platforms PLATFORMS: list[Platform] = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: MedcomBleConfigEntry) -> bool: """Set up Medcom BLE radiation monitor from a config entry.""" address = entry.unique_id @@ -31,16 +29,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: MedcomBleConfigEntry) -> 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/medcom_ble/coordinator.py b/homeassistant/components/medcom_ble/coordinator.py index 2b326c4196d521..eb7f91f3477aed 100644 --- a/homeassistant/components/medcom_ble/coordinator.py +++ b/homeassistant/components/medcom_ble/coordinator.py @@ -18,13 +18,17 @@ _LOGGER = logging.getLogger(__name__) +type MedcomBleConfigEntry = ConfigEntry[MedcomBleUpdateCoordinator] + class MedcomBleUpdateCoordinator(DataUpdateCoordinator[MedcomBleDevice]): """Coordinator for Medcom BLE radiation monitor data.""" - config_entry: ConfigEntry + config_entry: MedcomBleConfigEntry - def __init__(self, hass: HomeAssistant, entry: ConfigEntry, address: str) -> None: + def __init__( + self, hass: HomeAssistant, entry: MedcomBleConfigEntry, address: str + ) -> None: """Initialize the coordinator.""" super().__init__( hass, diff --git a/homeassistant/components/medcom_ble/sensor.py b/homeassistant/components/medcom_ble/sensor.py index cf78b5dc41aaba..6ca59c07908396 100644 --- a/homeassistant/components/medcom_ble/sensor.py +++ b/homeassistant/components/medcom_ble/sensor.py @@ -4,7 +4,6 @@ import logging -from homeassistant import config_entries from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, @@ -15,8 +14,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, UNIT_CPM -from .coordinator import MedcomBleUpdateCoordinator +from .const import UNIT_CPM +from .coordinator import MedcomBleConfigEntry, MedcomBleUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -32,12 +31,12 @@ async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: MedcomBleConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Medcom BLE radiation monitor sensors.""" - coordinator: MedcomBleUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities = [] _LOGGER.debug("got sensors: %s", coordinator.data.sensors) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index c18c64d73d162e..fce339d6b69884 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp[default]==2026.02.04"], + "requirements": ["yt-dlp[default]==2026.02.21"], "single_config_entry": true } diff --git a/homeassistant/components/media_source/models.py b/homeassistant/components/media_source/models.py index ac633e8753dbc7..3e43b6008b1829 100644 --- a/homeassistant/components/media_source/models.py +++ b/homeassistant/components/media_source/models.py @@ -83,7 +83,7 @@ async def async_browse(self) -> BrowseMediaSource: identifier=None, media_class=MediaClass.APP, media_content_type=MediaType.APP, - thumbnail=f"https://brands.home-assistant.io/_/{source.domain}/logo.png", + thumbnail=f"/api/brands/integration/{source.domain}/logo.png", title=source.name, can_play=False, can_expand=True, diff --git a/homeassistant/components/met/diagnostics.py b/homeassistant/components/met/diagnostics.py new file mode 100644 index 00000000000000..b5a37ac490d455 --- /dev/null +++ b/homeassistant/components/met/diagnostics.py @@ -0,0 +1,30 @@ +"""Diagnostics support for Met.no integration.""" + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant + +from .coordinator import MetWeatherConfigEntry + +TO_REDACT = [ + CONF_LATITUDE, + CONF_LONGITUDE, +] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: MetWeatherConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator_data = entry.runtime_data.data + + return { + "entry_data": async_redact_data(entry.data, TO_REDACT), + "data": { + "current_weather_data": coordinator_data.current_weather_data, + "daily_forecast": coordinator_data.daily_forecast, + "hourly_forecast": coordinator_data.hourly_forecast, + }, + } diff --git a/homeassistant/components/met_eireann/__init__.py b/homeassistant/components/met_eireann/__init__.py index 05be5134283743..cfbe05f5625111 100644 --- a/homeassistant/components/met_eireann/__init__.py +++ b/homeassistant/components/met_eireann/__init__.py @@ -1,32 +1,29 @@ """The met_eireann component.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import MetEireannUpdateCoordinator +from .coordinator import MetEireannConfigEntry, MetEireannUpdateCoordinator PLATFORMS = [Platform.WEATHER] -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: MetEireannConfigEntry +) -> bool: """Set up Met Éireann as config entry.""" coordinator = MetEireannUpdateCoordinator(hass, config_entry=config_entry) await coordinator.async_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: MetEireannConfigEntry +) -> bool: """Unload a 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/met_eireann/coordinator.py b/homeassistant/components/met_eireann/coordinator.py index fb8c85f6b8d34e..b2873c19724861 100644 --- a/homeassistant/components/met_eireann/coordinator.py +++ b/homeassistant/components/met_eireann/coordinator.py @@ -22,6 +22,8 @@ UPDATE_INTERVAL = timedelta(minutes=60) +type MetEireannConfigEntry = ConfigEntry[MetEireannUpdateCoordinator] + class MetEireannWeatherData: """Keep data for Met Éireann weather entities.""" diff --git a/homeassistant/components/met_eireann/weather.py b/homeassistant/components/met_eireann/weather.py index b6095c174f2a94..889e0ac6db5ea4 100644 --- a/homeassistant/components/met_eireann/weather.py +++ b/homeassistant/components/met_eireann/weather.py @@ -11,7 +11,6 @@ SingleCoordinatorWeatherEntity, WeatherEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, @@ -25,11 +24,10 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util from .const import CONDITION_MAP, DEFAULT_NAME, DOMAIN, FORECAST_MAP -from .coordinator import MetEireannWeatherData +from .coordinator import MetEireannConfigEntry, MetEireannUpdateCoordinator def format_condition(condition: str | None) -> str | None: @@ -43,11 +41,11 @@ def format_condition(condition: str | None) -> str | None: async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MetEireannConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a weather entity from a config_entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data entity_registry = er.async_get(hass) # Remove hourly entity from legacy config entries @@ -70,9 +68,7 @@ def _calculate_unique_id(config: Mapping[str, Any], hourly: bool) -> str: return f"{config[CONF_LATITUDE]}-{config[CONF_LONGITUDE]}{name_appendix}" -class MetEireannWeather( - SingleCoordinatorWeatherEntity[DataUpdateCoordinator[MetEireannWeatherData]] -): +class MetEireannWeather(SingleCoordinatorWeatherEntity[MetEireannUpdateCoordinator]): """Implementation of a Met Éireann weather condition.""" _attr_attribution = "Data provided by Met Éireann" @@ -86,7 +82,7 @@ class MetEireannWeather( def __init__( self, - coordinator: DataUpdateCoordinator[MetEireannWeatherData], + coordinator: MetEireannUpdateCoordinator, config: Mapping[str, Any], ) -> None: """Initialise the platform with a data instance and site.""" diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index 94918ab4d4fe00..5d1274e085d7a6 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -1,72 +1,38 @@ """Support for Meteo-France weather data.""" -from datetime import timedelta import logging from meteofrance_api.client import MeteoFranceClient from meteofrance_api.helpers import is_valid_warning_department -from meteofrance_api.model import CurrentPhenomenons, Forecast, Rain from requests import RequestException -import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( - CONF_CITY, COORDINATOR_ALERT, COORDINATOR_FORECAST, COORDINATOR_RAIN, DOMAIN, PLATFORMS, ) +from .coordinator import ( + MeteoFranceAlertUpdateCoordinator, + MeteoFranceForecastUpdateCoordinator, + MeteoFranceRainUpdateCoordinator, +) _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL_RAIN = timedelta(minutes=5) -SCAN_INTERVAL = timedelta(minutes=15) - - -CITY_SCHEMA = vol.Schema({vol.Required(CONF_CITY): cv.string}) - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up an Meteo-France account from a config entry.""" + """Set up a Meteo-France account from a config entry.""" hass.data.setdefault(DOMAIN, {}) client = MeteoFranceClient() - latitude = entry.data[CONF_LATITUDE] - longitude = entry.data[CONF_LONGITUDE] - - async def _async_update_data_forecast_forecast() -> Forecast: - """Fetch data from API endpoint.""" - return await hass.async_add_executor_job( - client.get_forecast, latitude, longitude - ) - async def _async_update_data_rain() -> Rain: - """Fetch data from API endpoint.""" - return await hass.async_add_executor_job(client.get_rain, latitude, longitude) - - async def _async_update_data_alert() -> CurrentPhenomenons: - """Fetch data from API endpoint.""" - assert isinstance(department, str) - return await hass.async_add_executor_job( - client.get_warning_current_phenomenons, department, 0, True - ) - - coordinator_forecast = DataUpdateCoordinator( - hass, - _LOGGER, - name=f"Météo-France forecast for city {entry.title}", - config_entry=entry, - update_method=_async_update_data_forecast_forecast, - update_interval=SCAN_INTERVAL, - ) + coordinator_forecast = MeteoFranceForecastUpdateCoordinator(hass, entry, client) coordinator_rain = None coordinator_alert = None @@ -77,14 +43,7 @@ async def _async_update_data_alert() -> CurrentPhenomenons: raise ConfigEntryNotReady # Check rain forecast. - coordinator_rain = DataUpdateCoordinator( - hass, - _LOGGER, - name=f"Météo-France rain for city {entry.title}", - config_entry=entry, - update_method=_async_update_data_rain, - update_interval=SCAN_INTERVAL_RAIN, - ) + coordinator_rain = MeteoFranceRainUpdateCoordinator(hass, entry, client) try: await coordinator_rain._async_refresh(log_failures=False) # noqa: SLF001 except RequestException: @@ -101,13 +60,11 @@ async def _async_update_data_alert() -> CurrentPhenomenons: ) if department is not None and is_valid_warning_department(department): if not hass.data[DOMAIN].get(department): - coordinator_alert = DataUpdateCoordinator( + coordinator_alert = MeteoFranceAlertUpdateCoordinator( hass, - _LOGGER, - name=f"Météo-France alert for department {department}", - config_entry=entry, - update_method=_async_update_data_alert, - update_interval=SCAN_INTERVAL, + entry, + client, + department, ) await coordinator_alert.async_refresh() diff --git a/homeassistant/components/meteo_france/coordinator.py b/homeassistant/components/meteo_france/coordinator.py new file mode 100644 index 00000000000000..8d09a21e12d5e4 --- /dev/null +++ b/homeassistant/components/meteo_france/coordinator.py @@ -0,0 +1,107 @@ +"""Support for Meteo-France weather data.""" + +from datetime import timedelta +import logging + +from meteofrance_api.client import MeteoFranceClient +from meteofrance_api.model import CurrentPhenomenons, Forecast, Rain + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL_RAIN = timedelta(minutes=5) +SCAN_INTERVAL = timedelta(minutes=15) + + +class MeteoFranceForecastUpdateCoordinator(DataUpdateCoordinator[Forecast]): + """Coordinator for Meteo-France forecast data.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + client: MeteoFranceClient, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"Météo-France forecast for city {entry.title}", + config_entry=entry, + update_interval=SCAN_INTERVAL, + ) + self._client = client + self._latitude = entry.data[CONF_LATITUDE] + self._longitude = entry.data[CONF_LONGITUDE] + + async def _async_update_data(self) -> Forecast: + """Get data from Meteo-France forecast.""" + return await self.hass.async_add_executor_job( + self._client.get_forecast, self._latitude, self._longitude + ) + + +class MeteoFranceRainUpdateCoordinator(DataUpdateCoordinator[Rain]): + """Coordinator for Meteo-France rain data.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + client: MeteoFranceClient, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"Météo-France rain for city {entry.title}", + config_entry=entry, + update_interval=SCAN_INTERVAL_RAIN, + ) + self._client = client + self._latitude = entry.data[CONF_LATITUDE] + self._longitude = entry.data[CONF_LONGITUDE] + + async def _async_update_data(self) -> Rain: + """Get data from Meteo-France rain.""" + return await self.hass.async_add_executor_job( + self._client.get_rain, self._latitude, self._longitude + ) + + +class MeteoFranceAlertUpdateCoordinator(DataUpdateCoordinator[CurrentPhenomenons]): + """Coordinator for Meteo-France alert data.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + client: MeteoFranceClient, + department: str, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"Météo-France alert for department {department}", + config_entry=entry, + update_interval=SCAN_INTERVAL, + ) + self._client = client + self._department = department + + async def _async_update_data(self) -> CurrentPhenomenons: + """Get data from Meteo-France alert.""" + return await self.hass.async_add_executor_job( + self._client.get_warning_current_phenomenons, self._department, 0, True + ) diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index 975fb038650156..1af9ead3a64278 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -48,6 +48,11 @@ MANUFACTURER, MODEL, ) +from .coordinator import ( + MeteoFranceAlertUpdateCoordinator, + MeteoFranceForecastUpdateCoordinator, + MeteoFranceRainUpdateCoordinator, +) @dataclass(frozen=True, kw_only=True) @@ -188,9 +193,13 @@ async def async_setup_entry( ) -> None: """Set up the Meteo-France sensor platform.""" data = hass.data[DOMAIN][entry.entry_id] - coordinator_forecast: DataUpdateCoordinator[Forecast] = data[COORDINATOR_FORECAST] - coordinator_rain: DataUpdateCoordinator[Rain] | None = data.get(COORDINATOR_RAIN) - coordinator_alert: DataUpdateCoordinator[CurrentPhenomenons] | None = data.get( + coordinator_forecast: MeteoFranceForecastUpdateCoordinator = data[ + COORDINATOR_FORECAST + ] + coordinator_rain: MeteoFranceRainUpdateCoordinator | None = data.get( + COORDINATOR_RAIN + ) + coordinator_alert: MeteoFranceAlertUpdateCoordinator | None = data.get( COORDINATOR_ALERT ) @@ -316,7 +325,7 @@ class MeteoFranceAlertSensor(MeteoFranceSensor[CurrentPhenomenons]): def __init__( self, - coordinator: DataUpdateCoordinator[CurrentPhenomenons], + coordinator: MeteoFranceAlertUpdateCoordinator, description: MeteoFranceSensorEntityDescription, ) -> None: """Initialize the Meteo-France sensor.""" @@ -333,10 +342,14 @@ def native_value(self) -> str | None: ) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return { - **readable_phenomenons_dict(self.coordinator.data.phenomenons_max_colors), + k: v + for k, v in readable_phenomenons_dict( + self.coordinator.data.phenomenons_max_colors + ).items() + if k is not None } diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index 9b3472e3312dd1..a4a137ded83af0 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -3,8 +3,6 @@ import logging import time -from meteofrance_api.model.forecast import Forecast as MeteoFranceForecast - from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_SUNNY, @@ -31,10 +29,7 @@ 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, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util from .const import ( @@ -47,6 +42,7 @@ MANUFACTURER, MODEL, ) +from .coordinator import MeteoFranceForecastUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -66,7 +62,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Meteo-France weather platform.""" - coordinator: DataUpdateCoordinator[MeteoFranceForecast] = hass.data[DOMAIN][ + coordinator: MeteoFranceForecastUpdateCoordinator = hass.data[DOMAIN][ entry.entry_id ][COORDINATOR_FORECAST] @@ -87,7 +83,7 @@ async def async_setup_entry( class MeteoFranceWeather( - CoordinatorEntity[DataUpdateCoordinator[MeteoFranceForecast]], WeatherEntity + CoordinatorEntity[MeteoFranceForecastUpdateCoordinator], WeatherEntity ): """Representation of a weather condition.""" @@ -101,13 +97,13 @@ class MeteoFranceWeather( ) def __init__( - self, coordinator: DataUpdateCoordinator[MeteoFranceForecast], mode: str + self, coordinator: MeteoFranceForecastUpdateCoordinator, mode: str ) -> None: """Initialise the platform with a data instance and station name.""" super().__init__(coordinator) - self._city_name = self.coordinator.data.position["name"] + self._attr_name = self.coordinator.data.position["name"] self._mode = mode - self._unique_id = f"{self.coordinator.data.position['lat']},{self.coordinator.data.position['lon']}" + self._attr_unique_id = f"{self.coordinator.data.position['lat']},{self.coordinator.data.position['lon']}" @callback def _handle_coordinator_update(self) -> None: @@ -118,16 +114,6 @@ def _handle_coordinator_update(self) -> None: self.hass, self.async_update_listeners(("daily", "hourly")) ) - @property - def unique_id(self) -> str: - """Return the unique id of the sensor.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return self._city_name - @property def device_info(self) -> DeviceInfo: """Return the device info.""" @@ -141,39 +127,39 @@ def device_info(self) -> DeviceInfo: ) @property - def condition(self): + def condition(self) -> str: """Return the current condition.""" return format_condition( self.coordinator.data.current_forecast["weather"]["desc"] ) @property - def native_temperature(self): + def native_temperature(self) -> float: """Return the temperature.""" return self.coordinator.data.current_forecast["T"]["value"] @property - def native_pressure(self): + def native_pressure(self) -> float: """Return the pressure.""" return self.coordinator.data.current_forecast["sea_level"] @property - def humidity(self): + def humidity(self) -> float: """Return the humidity.""" return self.coordinator.data.current_forecast["humidity"] @property - def native_wind_speed(self): + def native_wind_speed(self) -> float: """Return the wind speed.""" return self.coordinator.data.current_forecast["wind"]["speed"] @property - def native_wind_gust_speed(self): + def native_wind_gust_speed(self) -> float | None: """Return the wind gust speed.""" return self.coordinator.data.current_forecast["wind"].get("gust") @property - def wind_bearing(self): + def wind_bearing(self) -> float | None: """Return the wind bearing.""" wind_bearing = self.coordinator.data.current_forecast["wind"]["direction"] if wind_bearing != -1: diff --git a/homeassistant/components/meteoclimatic/__init__.py b/homeassistant/components/meteoclimatic/__init__.py index 99f72fe726baae..1d7f06aa2090b6 100644 --- a/homeassistant/components/meteoclimatic/__init__.py +++ b/homeassistant/components/meteoclimatic/__init__.py @@ -1,25 +1,27 @@ """Support for Meteoclimatic weather data.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN, PLATFORMS -from .coordinator import MeteoclimaticUpdateCoordinator +from .const import PLATFORMS +from .coordinator import MeteoclimaticConfigEntry, MeteoclimaticUpdateCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: MeteoclimaticConfigEntry +) -> bool: """Set up a Meteoclimatic entry.""" coordinator = MeteoclimaticUpdateCoordinator(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) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: MeteoclimaticConfigEntry +) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/meteoclimatic/coordinator.py b/homeassistant/components/meteoclimatic/coordinator.py index 2e9264dd3ef4db..7e6321c2b93a73 100644 --- a/homeassistant/components/meteoclimatic/coordinator.py +++ b/homeassistant/components/meteoclimatic/coordinator.py @@ -1,9 +1,8 @@ """Support for Meteoclimatic weather data.""" import logging -from typing import Any -from meteoclimatic import MeteoclimaticClient +from meteoclimatic import MeteoclimaticClient, Observation from meteoclimatic.exceptions import MeteoclimaticError from homeassistant.config_entries import ConfigEntry @@ -14,13 +13,15 @@ _LOGGER = logging.getLogger(__name__) +type MeteoclimaticConfigEntry = ConfigEntry[MeteoclimaticUpdateCoordinator] -class MeteoclimaticUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + +class MeteoclimaticUpdateCoordinator(DataUpdateCoordinator[Observation]): """Coordinator for Meteoclimatic weather data.""" - config_entry: ConfigEntry + config_entry: MeteoclimaticConfigEntry - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: MeteoclimaticConfigEntry) -> None: """Initialize the coordinator.""" self._station_code = entry.data[CONF_STATION_CODE] super().__init__( @@ -32,12 +33,11 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: ) self._meteoclimatic_client = MeteoclimaticClient() - async def _async_update_data(self) -> dict[str, Any]: + async def _async_update_data(self) -> Observation: """Obtain the latest data from Meteoclimatic.""" try: - data = await self.hass.async_add_executor_job( + return await self.hass.async_add_executor_job( self._meteoclimatic_client.weather_at_station, self._station_code ) except MeteoclimaticError as err: raise UpdateFailed(f"Error while retrieving data: {err}") from err - return data.__dict__ diff --git a/homeassistant/components/meteoclimatic/sensor.py b/homeassistant/components/meteoclimatic/sensor.py index 2d80ccda30cd8c..198e077021982d 100644 --- a/homeassistant/components/meteoclimatic/sensor.py +++ b/homeassistant/components/meteoclimatic/sensor.py @@ -1,12 +1,13 @@ """Support for Meteoclimatic sensor.""" +from typing import TYPE_CHECKING + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEGREE, PERCENTAGE, @@ -21,7 +22,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION, DOMAIN, MANUFACTURER, MODEL -from .coordinator import MeteoclimaticUpdateCoordinator +from .coordinator import MeteoclimaticConfigEntry, MeteoclimaticUpdateCoordinator SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -113,11 +114,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MeteoclimaticConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Meteoclimatic sensor platform.""" - coordinator: MeteoclimaticUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( [MeteoclimaticSensor(coordinator, description) for description in SENSOR_TYPES], @@ -140,26 +141,24 @@ def __init__( """Initialize the Meteoclimatic sensor.""" super().__init__(coordinator) self.entity_description = description - station = self.coordinator.data["station"] + station = coordinator.data.station self._attr_name = f"{station.name} {description.name}" self._attr_unique_id = f"{station.code}_{description.key}" - - @property - def device_info(self): - """Return the device info.""" - return DeviceInfo( + if TYPE_CHECKING: + assert coordinator.config_entry.unique_id is not None + self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, self.platform.config_entry.unique_id)}, + identifiers={(DOMAIN, coordinator.config_entry.unique_id)}, manufacturer=MANUFACTURER, model=MODEL, - name=self.coordinator.name, + name=coordinator.name, ) @property - def native_value(self): + def native_value(self) -> float | None: """Return the state of the sensor.""" return ( - getattr(self.coordinator.data["weather"], self.entity_description.key) + getattr(self.coordinator.data.weather, self.entity_description.key) if self.coordinator.data else None ) diff --git a/homeassistant/components/meteoclimatic/weather.py b/homeassistant/components/meteoclimatic/weather.py index ba74cfeca5e288..5474f10eb1bf17 100644 --- a/homeassistant/components/meteoclimatic/weather.py +++ b/homeassistant/components/meteoclimatic/weather.py @@ -1,9 +1,10 @@ """Support for Meteoclimatic weather service.""" +from typing import TYPE_CHECKING + from meteoclimatic import Condition from homeassistant.components.weather import WeatherEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -11,7 +12,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION, CONDITION_MAP, DOMAIN, MANUFACTURER, MODEL -from .coordinator import MeteoclimaticUpdateCoordinator +from .coordinator import MeteoclimaticConfigEntry, MeteoclimaticUpdateCoordinator def format_condition(condition): @@ -25,11 +26,11 @@ def format_condition(condition): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MeteoclimaticConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Meteoclimatic weather platform.""" - coordinator: MeteoclimaticUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities([MeteoclimaticWeather(coordinator)], False) @@ -47,56 +48,44 @@ class MeteoclimaticWeather( def __init__(self, coordinator: MeteoclimaticUpdateCoordinator) -> None: """Initialise the weather platform.""" super().__init__(coordinator) - self._unique_id = self.coordinator.data["station"].code - self._name = self.coordinator.data["station"].name - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def unique_id(self): - """Return the unique id of the sensor.""" - return self._unique_id - - @property - def device_info(self): - """Return the device info.""" - return DeviceInfo( + self._attr_unique_id = coordinator.data.station.code + self._attr_name = coordinator.data.station.name + if TYPE_CHECKING: + assert coordinator.config_entry.unique_id is not None + self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, self.platform.config_entry.unique_id)}, + identifiers={(DOMAIN, coordinator.config_entry.unique_id)}, manufacturer=MANUFACTURER, model=MODEL, - name=self.coordinator.name, + name=coordinator.name, ) @property - def condition(self): + def condition(self) -> str | None: """Return the current condition.""" - return format_condition(self.coordinator.data["weather"].condition) + return format_condition(self.coordinator.data.weather.condition) @property - def native_temperature(self): + def native_temperature(self) -> float | None: """Return the temperature.""" - return self.coordinator.data["weather"].temp_current + return self.coordinator.data.weather.temp_current @property - def humidity(self): + def humidity(self) -> float | None: """Return the humidity.""" - return self.coordinator.data["weather"].humidity_current + return self.coordinator.data.weather.humidity_current @property - def native_pressure(self): + def native_pressure(self) -> float | None: """Return the pressure.""" - return self.coordinator.data["weather"].pressure_current + return self.coordinator.data.weather.pressure_current @property - def native_wind_speed(self): + def native_wind_speed(self) -> float | None: """Return the wind speed.""" - return self.coordinator.data["weather"].wind_current + return self.coordinator.data.weather.wind_current @property - def wind_bearing(self): + def wind_bearing(self) -> float | None: """Return the wind bearing.""" - return self.coordinator.data["weather"].wind_bearing + return self.coordinator.data.weather.wind_bearing diff --git a/homeassistant/components/metoffice/__init__.py b/homeassistant/components/metoffice/__init__.py index 352d7f11f96d24..fc011a0821639c 100644 --- a/homeassistant/components/metoffice/__init__.py +++ b/homeassistant/components/metoffice/__init__.py @@ -3,9 +3,7 @@ from __future__ import annotations import asyncio -import logging -from datapoint.Forecast import Forecast from datapoint.Manager import Manager from homeassistant.config_entries import ConfigEntry @@ -19,93 +17,71 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator - -from .const import ( - DEFAULT_SCAN_INTERVAL, - DOMAIN, - METOFFICE_COORDINATES, - METOFFICE_DAILY_COORDINATOR, - METOFFICE_HOURLY_COORDINATOR, - METOFFICE_NAME, - METOFFICE_TWICE_DAILY_COORDINATOR, -) -from .helpers import fetch_data -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN +from .coordinator import ( + MetOfficeConfigEntry, + MetOfficeRuntimeData, + MetOfficeUpdateCoordinator, +) PLATFORMS = [Platform.SENSOR, Platform.WEATHER] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: MetOfficeConfigEntry) -> bool: """Set up a Met Office entry.""" - latitude = entry.data[CONF_LATITUDE] - longitude = entry.data[CONF_LONGITUDE] - api_key = entry.data[CONF_API_KEY] - site_name = entry.data[CONF_NAME] - - coordinates = f"{latitude}_{longitude}" + latitude: float = entry.data[CONF_LATITUDE] + longitude: float = entry.data[CONF_LONGITUDE] + api_key: str = entry.data[CONF_API_KEY] + site_name: str = entry.data[CONF_NAME] connection = Manager(api_key=api_key) - async def async_update_hourly() -> Forecast: - return await hass.async_add_executor_job( - fetch_data, connection, latitude, longitude, "hourly" - ) - - async def async_update_daily() -> Forecast: - return await hass.async_add_executor_job( - fetch_data, connection, latitude, longitude, "daily" - ) - - async def async_update_twice_daily() -> Forecast: - return await hass.async_add_executor_job( - fetch_data, connection, latitude, longitude, "twice-daily" - ) - - metoffice_hourly_coordinator = TimestampDataUpdateCoordinator( + metoffice_hourly_coordinator = MetOfficeUpdateCoordinator( hass, - _LOGGER, - config_entry=entry, + entry, name=f"MetOffice Hourly Coordinator for {site_name}", - update_method=async_update_hourly, - update_interval=DEFAULT_SCAN_INTERVAL, + connection=connection, + latitude=latitude, + longitude=longitude, + frequency="hourly", ) - metoffice_daily_coordinator = TimestampDataUpdateCoordinator( + metoffice_daily_coordinator = MetOfficeUpdateCoordinator( hass, - _LOGGER, - config_entry=entry, + entry, name=f"MetOffice Daily Coordinator for {site_name}", - update_method=async_update_daily, - update_interval=DEFAULT_SCAN_INTERVAL, + connection=connection, + latitude=latitude, + longitude=longitude, + frequency="daily", ) - metoffice_twice_daily_coordinator = TimestampDataUpdateCoordinator( + metoffice_twice_daily_coordinator = MetOfficeUpdateCoordinator( hass, - _LOGGER, - config_entry=entry, + entry, name=f"MetOffice Twice Daily Coordinator for {site_name}", - update_method=async_update_twice_daily, - update_interval=DEFAULT_SCAN_INTERVAL, + connection=connection, + latitude=latitude, + longitude=longitude, + frequency="twice-daily", ) - metoffice_hass_data = hass.data.setdefault(DOMAIN, {}) - metoffice_hass_data[entry.entry_id] = { - METOFFICE_HOURLY_COORDINATOR: metoffice_hourly_coordinator, - METOFFICE_DAILY_COORDINATOR: metoffice_daily_coordinator, - METOFFICE_TWICE_DAILY_COORDINATOR: metoffice_twice_daily_coordinator, - METOFFICE_NAME: site_name, - METOFFICE_COORDINATES: coordinates, - } - # Fetch initial data so we have data when entities subscribe await asyncio.gather( metoffice_hourly_coordinator.async_config_entry_first_refresh(), metoffice_daily_coordinator.async_config_entry_first_refresh(), ) + entry.runtime_data = MetOfficeRuntimeData( + coordinates=f"{latitude}_{longitude}", + hourly_coordinator=metoffice_hourly_coordinator, + daily_coordinator=metoffice_daily_coordinator, + twice_daily_coordinator=metoffice_twice_daily_coordinator, + name=site_name, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -113,12 +89,7 @@ async def async_update_twice_daily() -> Forecast: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - if not hass.data[DOMAIN]: - hass.data.pop(DOMAIN) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) def get_device_info(coordinates: str, name: str) -> DeviceInfo: diff --git a/homeassistant/components/metoffice/const.py b/homeassistant/components/metoffice/const.py index e5ba50f2a90e5e..9d2ba1c0d94824 100644 --- a/homeassistant/components/metoffice/const.py +++ b/homeassistant/components/metoffice/const.py @@ -38,13 +38,6 @@ DEFAULT_SCAN_INTERVAL = timedelta(minutes=15) -METOFFICE_COORDINATES = "metoffice_coordinates" -METOFFICE_HOURLY_COORDINATOR = "metoffice_hourly_coordinator" -METOFFICE_DAILY_COORDINATOR = "metoffice_daily_coordinator" -METOFFICE_TWICE_DAILY_COORDINATOR = "metoffice_twice_daily_coordinator" -METOFFICE_MONITORED_CONDITIONS = "metoffice_monitored_conditions" -METOFFICE_NAME = "metoffice_name" - CONDITION_CLASSES: dict[str, list[int]] = { ATTR_CONDITION_CLEAR_NIGHT: [0], ATTR_CONDITION_CLOUDY: [7, 8], diff --git a/homeassistant/components/metoffice/coordinator.py b/homeassistant/components/metoffice/coordinator.py new file mode 100644 index 00000000000000..322c4d61819c1e --- /dev/null +++ b/homeassistant/components/metoffice/coordinator.py @@ -0,0 +1,96 @@ +"""Data update coordinator for the Met Office integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging +from typing import Literal + +from datapoint.exceptions import APIException +from datapoint.Forecast import Forecast +from datapoint.Manager import Manager +from requests import HTTPError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import ( + TimestampDataUpdateCoordinator, + UpdateFailed, +) + +from .const import DEFAULT_SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + +type MetOfficeConfigEntry = ConfigEntry[MetOfficeRuntimeData] + + +@dataclass +class MetOfficeRuntimeData: + """Met Office config entry.""" + + coordinates: str + hourly_coordinator: MetOfficeUpdateCoordinator + daily_coordinator: MetOfficeUpdateCoordinator + twice_daily_coordinator: MetOfficeUpdateCoordinator + name: str + + +class MetOfficeUpdateCoordinator(TimestampDataUpdateCoordinator[Forecast]): + """Coordinator for Met Office forecast data.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + name: str, + connection: Manager, + latitude: float, + longitude: float, + frequency: Literal["daily", "twice-daily", "hourly"], + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=name, + config_entry=entry, + update_interval=DEFAULT_SCAN_INTERVAL, + ) + self._connection = connection + self._latitude = latitude + self._longitude = longitude + self._frequency = frequency + + async def _async_update_data(self) -> Forecast: + """Get data from Met Office.""" + return await self.hass.async_add_executor_job( + fetch_data, + self._connection, + self._latitude, + self._longitude, + self._frequency, + ) + + +def fetch_data( + connection: Manager, + latitude: float, + longitude: float, + frequency: Literal["daily", "twice-daily", "hourly"], +) -> Forecast: + """Fetch weather and forecast from Datapoint API.""" + try: + return connection.get_forecast( + latitude, longitude, frequency, convert_weather_code=False + ) + except (ValueError, APIException) as err: + _LOGGER.error("Check Met Office connection: %s", err.args) + raise UpdateFailed from err + except HTTPError as err: + if err.response.status_code == 401: + raise ConfigEntryAuthFailed from err + raise diff --git a/homeassistant/components/metoffice/helpers.py b/homeassistant/components/metoffice/helpers.py index 512faffafb4b52..e03face108bf9a 100644 --- a/homeassistant/components/metoffice/helpers.py +++ b/homeassistant/components/metoffice/helpers.py @@ -2,38 +2,7 @@ from __future__ import annotations -import logging -from typing import Any, Literal - -from datapoint.exceptions import APIException -from datapoint.Forecast import Forecast -from datapoint.Manager import Manager -from requests import HTTPError - -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.update_coordinator import UpdateFailed - -_LOGGER = logging.getLogger(__name__) - - -def fetch_data( - connection: Manager, - latitude: float, - longitude: float, - frequency: Literal["daily", "twice-daily", "hourly"], -) -> Forecast: - """Fetch weather and forecast from Datapoint API.""" - try: - return connection.get_forecast( - latitude, longitude, frequency, convert_weather_code=False - ) - except (ValueError, APIException) as err: - _LOGGER.error("Check Met Office connection: %s", err.args) - raise UpdateFailed from err - except HTTPError as err: - if err.response.status_code == 401: - raise ConfigEntryAuthFailed from err - raise +from typing import Any def get_attribute(data: dict[str, Any] | None, attr_name: str) -> Any | None: diff --git a/homeassistant/components/metoffice/sensor.py b/homeassistant/components/metoffice/sensor.py index 479edaa60ba1e9..e858a72c1c65d8 100644 --- a/homeassistant/components/metoffice/sensor.py +++ b/homeassistant/components/metoffice/sensor.py @@ -5,8 +5,6 @@ from dataclasses import dataclass from typing import Any -from datapoint.Forecast import Forecast - from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, EntityCategory, @@ -15,7 +13,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEGREE, PERCENTAGE, @@ -29,19 +26,14 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import get_device_info -from .const import ( - ATTRIBUTION, - CONDITION_MAP, - DOMAIN, - METOFFICE_COORDINATES, - METOFFICE_HOURLY_COORDINATOR, - METOFFICE_NAME, +from .const import ATTRIBUTION, CONDITION_MAP, DOMAIN +from .coordinator import ( + MetOfficeConfigEntry, + MetOfficeRuntimeData, + MetOfficeUpdateCoordinator, ) from .helpers import get_attribute @@ -176,19 +168,19 @@ class MetOfficeSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MetOfficeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Met Office weather sensor platform.""" entity_registry = er.async_get(hass) - hass_data = hass.data[DOMAIN][entry.entry_id] + hass_data = entry.runtime_data # Remove daily entities from legacy config entries for description in SENSOR_TYPES: if entity_id := entity_registry.async_get_entity_id( SENSOR_DOMAIN, DOMAIN, - f"{description.key}_{hass_data[METOFFICE_COORDINATES]}_daily", + f"{description.key}_{hass_data.coordinates}_daily", ): entity_registry.async_remove(entity_id) @@ -196,20 +188,20 @@ async def async_setup_entry( if entity_id := entity_registry.async_get_entity_id( SENSOR_DOMAIN, DOMAIN, - f"visibility_distance_{hass_data[METOFFICE_COORDINATES]}_daily", + f"visibility_distance_{hass_data.coordinates}_daily", ): entity_registry.async_remove(entity_id) if entity_id := entity_registry.async_get_entity_id( SENSOR_DOMAIN, DOMAIN, - f"visibility_distance_{hass_data[METOFFICE_COORDINATES]}", + f"visibility_distance_{hass_data.coordinates}", ): entity_registry.async_remove(entity_id) async_add_entities( [ MetOfficeCurrentSensor( - hass_data[METOFFICE_HOURLY_COORDINATOR], + hass_data.hourly_coordinator, hass_data, description, ) @@ -220,7 +212,7 @@ async def async_setup_entry( class MetOfficeCurrentSensor( - CoordinatorEntity[DataUpdateCoordinator[Forecast]], SensorEntity + CoordinatorEntity[MetOfficeUpdateCoordinator], SensorEntity ): """Implementation of a Met Office current weather condition sensor.""" @@ -231,8 +223,8 @@ class MetOfficeCurrentSensor( def __init__( self, - coordinator: DataUpdateCoordinator[Forecast], - hass_data: dict[str, Any], + coordinator: MetOfficeUpdateCoordinator, + hass_data: MetOfficeRuntimeData, description: MetOfficeSensorEntityDescription, ) -> None: """Initialize the sensor.""" @@ -241,9 +233,9 @@ def __init__( self.entity_description = description self._attr_device_info = get_device_info( - coordinates=hass_data[METOFFICE_COORDINATES], name=hass_data[METOFFICE_NAME] + coordinates=hass_data.coordinates, name=hass_data.name ) - self._attr_unique_id = f"{description.key}_{hass_data[METOFFICE_COORDINATES]}" + self._attr_unique_id = f"{description.key}_{hass_data.coordinates}" @property def native_value(self) -> StateType: diff --git a/homeassistant/components/metoffice/weather.py b/homeassistant/components/metoffice/weather.py index 5624faebfb2a2a..62202333f20e65 100644 --- a/homeassistant/components/metoffice/weather.py +++ b/homeassistant/components/metoffice/weather.py @@ -5,8 +5,6 @@ from datetime import datetime from typing import Any, cast -from datapoint.Forecast import Forecast - from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, ATTR_FORECAST_IS_DAYTIME, @@ -25,7 +23,6 @@ Forecast as WeatherForecast, WeatherEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( UnitOfLength, UnitOfPressure, @@ -35,7 +32,6 @@ 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 TimestampDataUpdateCoordinator from . import get_device_info from .const import ( @@ -45,39 +41,39 @@ DAY_FORECAST_ATTRIBUTE_MAP, DOMAIN, HOURLY_FORECAST_ATTRIBUTE_MAP, - METOFFICE_COORDINATES, - METOFFICE_DAILY_COORDINATOR, - METOFFICE_HOURLY_COORDINATOR, - METOFFICE_NAME, - METOFFICE_TWICE_DAILY_COORDINATOR, NIGHT_FORECAST_ATTRIBUTE_MAP, ) +from .coordinator import ( + MetOfficeConfigEntry, + MetOfficeRuntimeData, + MetOfficeUpdateCoordinator, +) from .helpers import get_attribute async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MetOfficeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Met Office weather sensor platform.""" entity_registry = er.async_get(hass) - hass_data = hass.data[DOMAIN][entry.entry_id] + hass_data = entry.runtime_data # Remove daily entity from legacy config entries if entity_id := entity_registry.async_get_entity_id( WEATHER_DOMAIN, DOMAIN, - f"{hass_data[METOFFICE_COORDINATES]}_daily", + f"{hass_data.coordinates}_daily", ): entity_registry.async_remove(entity_id) async_add_entities( [ MetOfficeWeather( - hass_data[METOFFICE_DAILY_COORDINATOR], - hass_data[METOFFICE_HOURLY_COORDINATOR], - hass_data[METOFFICE_TWICE_DAILY_COORDINATOR], + hass_data.daily_coordinator, + hass_data.hourly_coordinator, + hass_data.twice_daily_coordinator, hass_data, ) ], @@ -153,9 +149,9 @@ def get_mapped_attribute(attr: str) -> Any: class MetOfficeWeather( CoordinatorWeatherEntity[ - TimestampDataUpdateCoordinator[Forecast], - TimestampDataUpdateCoordinator[Forecast], - TimestampDataUpdateCoordinator[Forecast], + MetOfficeUpdateCoordinator, + MetOfficeUpdateCoordinator, + MetOfficeUpdateCoordinator, ] ): """Implementation of a Met Office weather condition.""" @@ -177,10 +173,10 @@ class MetOfficeWeather( def __init__( self, - coordinator_daily: TimestampDataUpdateCoordinator[Forecast], - coordinator_hourly: TimestampDataUpdateCoordinator[Forecast], - coordinator_twice_daily: TimestampDataUpdateCoordinator[Forecast], - hass_data: dict[str, Any], + coordinator_daily: MetOfficeUpdateCoordinator, + coordinator_hourly: MetOfficeUpdateCoordinator, + coordinator_twice_daily: MetOfficeUpdateCoordinator, + hass_data: MetOfficeRuntimeData, ) -> None: """Initialise the platform with a data instance.""" observation_coordinator = coordinator_hourly @@ -192,9 +188,9 @@ def __init__( ) self._attr_device_info = get_device_info( - coordinates=hass_data[METOFFICE_COORDINATES], name=hass_data[METOFFICE_NAME] + coordinates=hass_data.coordinates, name=hass_data.name ) - self._attr_unique_id = hass_data[METOFFICE_COORDINATES] + self._attr_unique_id = hass_data.coordinates @property def condition(self) -> str | None: @@ -266,7 +262,7 @@ def wind_bearing(self) -> float | None: def _async_forecast_daily(self) -> list[WeatherForecast] | None: """Return the daily forecast in native units.""" coordinator = cast( - TimestampDataUpdateCoordinator[Forecast], + MetOfficeUpdateCoordinator, self.forecast_coordinators["daily"], ) timesteps = coordinator.data.timesteps @@ -283,7 +279,7 @@ def _async_forecast_daily(self) -> list[WeatherForecast] | None: def _async_forecast_hourly(self) -> list[WeatherForecast] | None: """Return the hourly forecast in native units.""" coordinator = cast( - TimestampDataUpdateCoordinator[Forecast], + MetOfficeUpdateCoordinator, self.forecast_coordinators["hourly"], ) @@ -301,7 +297,7 @@ def _async_forecast_hourly(self) -> list[WeatherForecast] | None: def _async_forecast_twice_daily(self) -> list[WeatherForecast] | None: """Return the twice daily forecast in native units.""" coordinator = cast( - TimestampDataUpdateCoordinator[Forecast], + MetOfficeUpdateCoordinator, self.forecast_coordinators["twice_daily"], ) timesteps = coordinator.data.timesteps diff --git a/homeassistant/components/microbees/__init__.py b/homeassistant/components/microbees/__init__.py index 56ce18a028666b..af5d4aa32c782d 100644 --- a/homeassistant/components/microbees/__init__.py +++ b/homeassistant/components/microbees/__init__.py @@ -13,22 +13,25 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_entry_oauth2_flow -from .const import DOMAIN, PLATFORMS +from .const import PLATFORMS from .coordinator import MicroBeesUpdateCoordinator _LOGGER = logging.getLogger(__name__) +type MicroBeesConfigEntry = ConfigEntry[HomeAssistantMicroBeesData] + + @dataclass(frozen=True, kw_only=True) class HomeAssistantMicroBeesData: - """Microbees data stored in the Home Assistant data object.""" + """Microbees data stored in the config entry runtime_data.""" connector: MicroBees coordinator: MicroBeesUpdateCoordinator session: config_entry_oauth2_flow.OAuth2Session -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry(hass: HomeAssistant, entry: MicroBeesConfigEntry) -> bool: """Migrate entry.""" _LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version) @@ -45,7 +48,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: MicroBeesConfigEntry) -> bool: """Set up microBees from a config entry.""" implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( @@ -67,7 +70,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: microbees = MicroBees(token=session.token[CONF_ACCESS_TOKEN]) coordinator = MicroBeesUpdateCoordinator(hass, entry, microbees) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantMicroBeesData( + entry.runtime_data = HomeAssistantMicroBeesData( connector=microbees, coordinator=coordinator, session=session, @@ -76,9 +79,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: MicroBeesConfigEntry) -> 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/microbees/binary_sensor.py b/homeassistant/components/microbees/binary_sensor.py index 1dc2a8d9702e66..ae91df580d319c 100644 --- a/homeassistant/components/microbees/binary_sensor.py +++ b/homeassistant/components/microbees/binary_sensor.py @@ -7,11 +7,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 MicroBeesConfigEntry from .coordinator import MicroBeesUpdateCoordinator from .entity import MicroBeesEntity @@ -37,13 +36,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MicroBeesConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the microBees binary sensor platform.""" - coordinator: MicroBeesUpdateCoordinator = hass.data[DOMAIN][ - entry.entry_id - ].coordinator + coordinator = entry.runtime_data.coordinator async_add_entities( MBBinarySensor(coordinator, entity_description, bee_id, binary_sensor.id) for bee_id, bee in coordinator.data.bees.items() diff --git a/homeassistant/components/microbees/button.py b/homeassistant/components/microbees/button.py index ca3a76753a7fca..7cb315ff118c94 100644 --- a/homeassistant/components/microbees/button.py +++ b/homeassistant/components/microbees/button.py @@ -3,11 +3,10 @@ from typing import Any from homeassistant.components.button import ButtonEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN +from . import MicroBeesConfigEntry from .coordinator import MicroBeesUpdateCoordinator from .entity import MicroBeesActuatorEntity @@ -16,13 +15,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MicroBeesConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the microBees button platform.""" - coordinator: MicroBeesUpdateCoordinator = hass.data[DOMAIN][ - entry.entry_id - ].coordinator + coordinator = entry.runtime_data.coordinator async_add_entities( MBButton(coordinator, bee_id, button.id) for bee_id, bee in coordinator.data.bees.items() diff --git a/homeassistant/components/microbees/climate.py b/homeassistant/components/microbees/climate.py index 554ca3b32ccc74..8d546bc6c70a41 100644 --- a/homeassistant/components/microbees/climate.py +++ b/homeassistant/components/microbees/climate.py @@ -7,13 +7,12 @@ ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry 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 .const import DOMAIN +from . import MicroBeesConfigEntry from .coordinator import MicroBeesUpdateCoordinator from .entity import MicroBeesActuatorEntity @@ -27,13 +26,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MicroBeesConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the microBees climate platform.""" - coordinator: MicroBeesUpdateCoordinator = hass.data[DOMAIN][ - entry.entry_id - ].coordinator + coordinator = entry.runtime_data.coordinator async_add_entities( MBClimate( coordinator, diff --git a/homeassistant/components/microbees/coordinator.py b/homeassistant/components/microbees/coordinator.py index 0094dc33e81cec..67580da50db716 100644 --- a/homeassistant/components/microbees/coordinator.py +++ b/homeassistant/components/microbees/coordinator.py @@ -1,19 +1,24 @@ """The microBees Coordinator.""" +from __future__ import annotations + import asyncio from dataclasses import dataclass from datetime import timedelta from http import HTTPStatus import logging +from typing import TYPE_CHECKING import aiohttp from microBeesPy import Actuator, Bee, MicroBees, MicroBeesException, Sensor -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +if TYPE_CHECKING: + from . import MicroBeesConfigEntry + _LOGGER = logging.getLogger(__name__) @@ -29,10 +34,13 @@ class MicroBeesCoordinatorData: class MicroBeesUpdateCoordinator(DataUpdateCoordinator[MicroBeesCoordinatorData]): """MicroBees coordinator.""" - config_entry: ConfigEntry + config_entry: MicroBeesConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, microbees: MicroBees + self, + hass: HomeAssistant, + config_entry: MicroBeesConfigEntry, + microbees: MicroBees, ) -> None: """Initialize microBees coordinator.""" super().__init__( diff --git a/homeassistant/components/microbees/cover.py b/homeassistant/components/microbees/cover.py index fe87fcddd625bc..b09797e57ba478 100644 --- a/homeassistant/components/microbees/cover.py +++ b/homeassistant/components/microbees/cover.py @@ -9,14 +9,12 @@ 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 homeassistant.helpers.event import async_call_later -from .const import DOMAIN -from .coordinator import MicroBeesUpdateCoordinator +from . import MicroBeesConfigEntry from .entity import MicroBeesEntity COVER_IDS = {47: "roller_shutter"} @@ -24,13 +22,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MicroBeesConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the microBees cover platform.""" - coordinator: MicroBeesUpdateCoordinator = hass.data[DOMAIN][ - entry.entry_id - ].coordinator + coordinator = entry.runtime_data.coordinator async_add_entities( MBCover( diff --git a/homeassistant/components/microbees/light.py b/homeassistant/components/microbees/light.py index a7ff60dc64a1e0..4a791b0620f3ce 100644 --- a/homeassistant/components/microbees/light.py +++ b/homeassistant/components/microbees/light.py @@ -3,25 +3,22 @@ from typing import Any from homeassistant.components.light import ATTR_RGBW_COLOR, ColorMode, LightEntity -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 . import MicroBeesConfigEntry from .coordinator import MicroBeesUpdateCoordinator from .entity import MicroBeesActuatorEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MicroBeesConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Config entry.""" - coordinator: MicroBeesUpdateCoordinator = hass.data[DOMAIN][ - entry.entry_id - ].coordinator + coordinator = entry.runtime_data.coordinator async_add_entities( MBLight(coordinator, bee_id, light.id) for bee_id, bee in coordinator.data.bees.items() diff --git a/homeassistant/components/microbees/sensor.py b/homeassistant/components/microbees/sensor.py index e4be463ab101b5..85d27671c92272 100644 --- a/homeassistant/components/microbees/sensor.py +++ b/homeassistant/components/microbees/sensor.py @@ -8,7 +8,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, @@ -19,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN +from . import MicroBeesConfigEntry from .coordinator import MicroBeesUpdateCoordinator from .entity import MicroBeesEntity @@ -64,11 +63,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MicroBeesConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id].coordinator + coordinator = entry.runtime_data.coordinator async_add_entities( MBSensor(coordinator, desc, bee_id, sensor.id) diff --git a/homeassistant/components/microbees/switch.py b/homeassistant/components/microbees/switch.py index deda2d78d09342..ee3e3e21241234 100644 --- a/homeassistant/components/microbees/switch.py +++ b/homeassistant/components/microbees/switch.py @@ -3,12 +3,11 @@ from typing import Any from homeassistant.components.switch import SwitchEntity -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 . import MicroBeesConfigEntry from .coordinator import MicroBeesUpdateCoordinator from .entity import MicroBeesActuatorEntity @@ -18,11 +17,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MicroBeesConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id].coordinator + coordinator = entry.runtime_data.coordinator async_add_entities( MBSwitch(coordinator, bee_id, switch.id) diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index 98ff8430a0a8a9..6d0d9c5db1aa15 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -188,6 +188,7 @@ class ProgramPhaseTumbleDryer(MieleEnum, missing_to_none=True): finished = 522, 11012 extra_dry = 523 hand_iron = 524 + hygiene_drying = 525 moisten = 526 thermo_spin = 527 timed_drying = 528 @@ -489,7 +490,7 @@ class DishWasherProgramId(MieleEnum, missing_to_none=True): no_program = 0, -1 intensive = 1, 26, 205 maintenance = 2, 27, 214 - eco = 3, 28, 200 + eco = 3, 22, 28, 200 automatic = 6, 7, 31, 32, 202 solar_save = 9, 34 gentle = 10, 35, 210 @@ -499,6 +500,7 @@ class DishWasherProgramId(MieleEnum, missing_to_none=True): pasta_paela = 14 tall_items = 17, 42 glasses_warm = 19 + quick_intense = 21 normal = 30 power_wash = 44, 204 comfort_wash = 203 @@ -617,11 +619,11 @@ class OvenProgramId(MieleEnum, missing_to_none=True): evaporate_water = 327 shabbat_program = 335 yom_tov = 336 - drying = 357 + drying = 357, 2028 heat_crockery = 358 - prove_dough = 359 + prove_dough = 359, 2023 low_temperature_cooking = 360 - steam_cooking = 361 + steam_cooking = 8, 361 keeping_warm = 362 apple_sponge = 364 apple_pie = 365 @@ -668,9 +670,9 @@ class OvenProgramId(MieleEnum, missing_to_none=True): saddle_of_roebuck = 456 salmon_fillet = 461 potato_cheese_gratin = 464 - trout = 486 - carp = 491 - salmon_trout = 492 + trout = 486, 2224 + carp = 491, 2233 + salmon_trout = 492, 2241 springform_tin_15cm = 496 springform_tin_20cm = 497 springform_tin_25cm = 498 @@ -736,137 +738,15 @@ class OvenProgramId(MieleEnum, missing_to_none=True): pork_belly = 701 pikeperch_fillet_with_vegetables = 702 steam_bake = 99001 - - -class DishWarmerProgramId(MieleEnum, missing_to_none=True): - """Program Id codes for dish warmers.""" - - no_program = 0, -1 - warm_cups_glasses = 1 - warm_dishes_plates = 2 - keep_warm = 3 - slow_roasting = 4 - - -class RobotVacuumCleanerProgramId(MieleEnum, missing_to_none=True): - """Program Id codes for robot vacuum cleaners.""" - - no_program = 0, -1 - auto = 1 - spot = 2 - turbo = 3 - silent = 4 - - -class CoffeeSystemProgramId(MieleEnum, missing_to_none=True): - """Program Id codes for coffee systems.""" - - no_program = 0, -1 - - check_appliance = 17004 - - # profile 1 - ristretto = 24000, 24032, 24064, 24096, 24128 - espresso = 24001, 24033, 24065, 24097, 24129 - coffee = 24002, 24034, 24066, 24098, 24130 - long_coffee = 24003, 24035, 24067, 24099, 24131 - cappuccino = 24004, 24036, 24068, 24100, 24132 - cappuccino_italiano = 24005, 24037, 24069, 24101, 24133 - latte_macchiato = 24006, 24038, 24070, 24102, 24134 - espresso_macchiato = 24007, 24039, 24071, 24135 - cafe_au_lait = 24008, 24040, 24072, 24104, 24136 - caffe_latte = 24009, 24041, 24073, 24105, 24137 - flat_white = 24012, 24044, 24076, 24108, 24140 - very_hot_water = 24013, 24045, 24077, 24109, 24141 - hot_water = 24014, 24046, 24078, 24110, 24142 - hot_milk = 24015, 24047, 24079, 24111, 24143 - milk_foam = 24016, 24048, 24080, 24112, 24144 - black_tea = 24017, 24049, 24081, 24113, 24145 - herbal_tea = 24018, 24050, 24082, 24114, 24146 - fruit_tea = 24019, 24051, 24083, 24115, 24147 - green_tea = 24020, 24052, 24084, 24116, 24148 - white_tea = 24021, 24053, 24085, 24117, 24149 - japanese_tea = 24022, 29054, 24086, 24118, 24150 - # special programs - coffee_pot = 24400 - barista_assistant = 24407 - # machine settings menu - appliance_settings = ( - 16016, # display brightness - 16018, # volume - 16019, # buttons volume - 16020, # child lock - 16021, # water hardness - 16027, # welcome sound - 16033, # connection status - 16035, # remote control - 16037, # remote update - 24500, # total dispensed - 24502, # lights appliance on - 24503, # lights appliance off - 24504, # turn off lights after - 24506, # altitude - 24513, # performance mode - 24516, # turn off after - 24537, # advanced mode - 24542, # tea timer - 24549, # total coffee dispensed - 24550, # total tea dispensed - 24551, # total ristretto - 24552, # total cappuccino - 24553, # total espresso - 24554, # total coffee - 24555, # total long coffee - 24556, # total italian cappuccino - 24557, # total latte macchiato - 24558, # total caffe latte - 24560, # total espresso macchiato - 24562, # total flat white - 24563, # total coffee with milk - 24564, # total black tea - 24565, # total herbal tea - 24566, # total fruit tea - 24567, # total green tea - 24568, # total white tea - 24569, # total japanese tea - 24571, # total milk foam - 24572, # total hot milk - 24573, # total hot water - 24574, # total very hot water - 24575, # counter to descaling - 24576, # counter to brewing unit degreasing - 24800, # maintenance - 24801, # profiles settings menu - 24813, # add profile - ) - appliance_rinse = 24750, 24759, 24773, 24787, 24788 - intermediate_rinsing = 24758 - automatic_maintenance = 24778 - descaling = 24751 - brewing_unit_degrease = 24753 - milk_pipework_rinse = 24754 - milk_pipework_clean = 24789 - - -class SteamOvenMicroProgramId(MieleEnum, missing_to_none=True): - """Program Id codes for steam oven micro combo.""" - - no_program = 0, -1 - steam_cooking = 8 - microwave = 19 - popcorn = 53 - quick_mw = 54 sous_vide = 72 eco_steam_cooking = 75 rapid_steam_cooking = 77 - descale = 326 menu_cooking = 330 reheating_with_steam = 2018 defrosting_with_steam = 2019 blanching = 2020 bottling = 2021 sterilize_crockery = 2022 - prove_dough = 2023 soak = 2027 reheating_with_microwave = 2029 defrosting_with_microwave = 2030 @@ -1020,18 +900,15 @@ class SteamOvenMicroProgramId(MieleEnum, missing_to_none=True): gilt_head_bream_fillet = 2220 codfish_piece = 2221, 2232 codfish_fillet = 2222, 2231 - trout = 2224 pike_fillet = 2225 pike_piece = 2226 halibut_fillet_2_cm = 2227 halibut_fillet_3_cm = 2230 - carp = 2233 salmon_fillet_2_cm = 2234 salmon_fillet_3_cm = 2235 salmon_steak_2_cm = 2238 salmon_steak_3_cm = 2239 salmon_piece = 2240 - salmon_trout = 2241 iridescent_shark_fillet = 2244 red_snapper_fillet_2_cm = 2245 red_snapper_fillet_3_cm = 2248 @@ -1268,6 +1145,116 @@ class SteamOvenMicroProgramId(MieleEnum, missing_to_none=True): round_grain_rice_general_rapid_steam_cooking = 3411 +class DishWarmerProgramId(MieleEnum, missing_to_none=True): + """Program Id codes for dish warmers.""" + + no_program = 0, -1 + warm_cups_glasses = 1 + warm_dishes_plates = 2 + keep_warm = 3 + slow_roasting = 4 + + +class RobotVacuumCleanerProgramId(MieleEnum, missing_to_none=True): + """Program Id codes for robot vacuum cleaners.""" + + no_program = 0, -1 + auto = 1 + spot = 2 + turbo = 3 + silent = 4 + + +class CoffeeSystemProgramId(MieleEnum, missing_to_none=True): + """Program Id codes for coffee systems.""" + + no_program = 0, -1 + + check_appliance = 17004 + + # profile 1 + ristretto = 24000, 24032, 24064, 24096, 24128 + espresso = 24001, 24033, 24065, 24097, 24129 + coffee = 24002, 24034, 24066, 24098, 24130 + long_coffee = 24003, 24035, 24067, 24099, 24131 + cappuccino = 24004, 24036, 24068, 24100, 24132 + cappuccino_italiano = 24005, 24037, 24069, 24101, 24133 + latte_macchiato = 24006, 24038, 24070, 24102, 24134 + espresso_macchiato = 24007, 24039, 24071, 24135 + cafe_au_lait = 24008, 24040, 24072, 24104, 24136 + caffe_latte = 24009, 24041, 24073, 24105, 24137 + flat_white = 24012, 24044, 24076, 24108, 24140 + very_hot_water = 24013, 24045, 24077, 24109, 24141 + hot_water = 24014, 24046, 24078, 24110, 24142 + hot_milk = 24015, 24047, 24079, 24111, 24143 + milk_foam = 24016, 24048, 24080, 24112, 24144 + black_tea = 24017, 24049, 24081, 24113, 24145 + herbal_tea = 24018, 24050, 24082, 24114, 24146 + fruit_tea = 24019, 24051, 24083, 24115, 24147 + green_tea = 24020, 24052, 24084, 24116, 24148 + white_tea = 24021, 24053, 24085, 24117, 24149 + japanese_tea = 24022, 29054, 24086, 24118, 24150 + # special programs + coffee_pot = 24400 + barista_assistant = 24407 + # machine settings menu + appliance_settings = ( + 16016, # display brightness + 16018, # volume + 16019, # buttons volume + 16020, # child lock + 16021, # water hardness + 16027, # welcome sound + 16033, # connection status + 16035, # remote control + 16037, # remote update + 24500, # total dispensed + 24502, # lights appliance on + 24503, # lights appliance off + 24504, # turn off lights after + 24506, # altitude + 24513, # performance mode + 24516, # turn off after + 24537, # advanced mode + 24542, # tea timer + 24549, # total coffee dispensed + 24550, # total tea dispensed + 24551, # total ristretto + 24552, # total cappuccino + 24553, # total espresso + 24554, # total coffee + 24555, # total long coffee + 24556, # total italian cappuccino + 24557, # total latte macchiato + 24558, # total caffe latte + 24560, # total espresso macchiato + 24562, # total flat white + 24563, # total coffee with milk + 24564, # total black tea + 24565, # total herbal tea + 24566, # total fruit tea + 24567, # total green tea + 24568, # total white tea + 24569, # total japanese tea + 24571, # total milk foam + 24572, # total hot milk + 24573, # total hot water + 24574, # total very hot water + 24575, # counter to descaling + 24576, # counter to brewing unit degreasing + 24800, # maintenance + 24801, # profiles settings menu + 24813, # add profile + ) + appliance_rinse = 24750, 24759, 24773, 24787, 24788 + intermediate_rinsing = 24758 + automatic_maintenance = 24778 + descaling = 24751 + brewing_unit_degrease = 24753 + milk_pipework_rinse = 24754 + milk_pipework_clean = 24789 + + PROGRAM_IDS: dict[int, type[MieleEnum]] = { MieleAppliance.WASHING_MACHINE: WashingMachineProgramId, MieleAppliance.TUMBLE_DRYER: TumbleDryerProgramId, @@ -1278,7 +1265,7 @@ class SteamOvenMicroProgramId(MieleEnum, missing_to_none=True): MieleAppliance.STEAM_OVEN_MK2: OvenProgramId, MieleAppliance.STEAM_OVEN: OvenProgramId, MieleAppliance.STEAM_OVEN_COMBI: OvenProgramId, - MieleAppliance.STEAM_OVEN_MICRO: SteamOvenMicroProgramId, + MieleAppliance.STEAM_OVEN_MICRO: OvenProgramId, MieleAppliance.WASHER_DRYER: WashingMachineProgramId, MieleAppliance.ROBOT_VACUUM_CLEANER: RobotVacuumCleanerProgramId, MieleAppliance.COFFEE_SYSTEM: CoffeeSystemProgramId, diff --git a/homeassistant/components/miele/icons.json b/homeassistant/components/miele/icons.json index 364c741b6cc03f..64294e18d6ed72 100644 --- a/homeassistant/components/miele/icons.json +++ b/homeassistant/components/miele/icons.json @@ -32,6 +32,12 @@ "core_temperature": { "default": "mdi:thermometer-probe" }, + "degreasing_counter": { + "default": "mdi:hydro-power" + }, + "descaling_counter": { + "default": "mdi:water-alert-outline" + }, "drying_step": { "default": "mdi:water-outline" }, @@ -44,6 +50,9 @@ "finish": { "default": "mdi:clock-end" }, + "milk_cleaning_counter": { + "default": "mdi:pipe" + }, "plate": { "default": "mdi:circle-outline", "state": { diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py index 32fb80c3eddd35..9802000e8c42d4 100644 --- a/homeassistant/components/miele/sensor.py +++ b/homeassistant/components/miele/sensor.py @@ -759,6 +759,36 @@ class MieleSensorDefinition[T: (MieleDevice, MieleFillingLevel)]: entity_category=EntityCategory.DIAGNOSTIC, ), ), + MieleSensorDefinition( + types=(MieleAppliance.COFFEE_SYSTEM,), + description=MieleSensorDescription[MieleFillingLevel]( + key="descaling_counter", + translation_key="descaling_counter", + value_fn=lambda value: value.descaling_counter, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + MieleSensorDefinition( + types=(MieleAppliance.COFFEE_SYSTEM,), + description=MieleSensorDescription[MieleFillingLevel]( + key="degreasing_counter", + translation_key="degreasing_counter", + value_fn=lambda value: value.degreasing_counter, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + MieleSensorDefinition( + types=(MieleAppliance.COFFEE_SYSTEM,), + description=MieleSensorDescription[MieleFillingLevel]( + key="milk_cleaning_counter", + translation_key="milk_cleaning_counter", + value_fn=lambda value: value.milk_cleaning_counter, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), ) diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index 1bd95191ca7c59..0fb35e5b0145f8 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -206,6 +206,12 @@ "core_temperature": { "name": "Core temperature" }, + "degreasing_counter": { + "name": "Degreasing cycles" + }, + "descaling_counter": { + "name": "Descaling cycles" + }, "drying_step": { "name": "Drying step", "state": { @@ -231,6 +237,9 @@ "finish": { "name": "Finish" }, + "milk_cleaning_counter": { + "name": "Milk pipework cleaning cycles" + }, "plate": { "name": "Plate {plate_no}", "state": { @@ -465,6 +474,7 @@ "drain_spin": "Drain/spin", "drop_cookies_1_tray": "Drop cookies (1 tray)", "drop_cookies_2_trays": "Drop cookies (2 trays)", + "drying": "Drying", "duck": "Duck", "dutch_hash": "Dutch hash", "easy_care": "Easy care", @@ -749,6 +759,7 @@ "pyrolytic": "Pyrolytic", "quiche_lorraine": "Quiche Lorraine", "quick_hygiene": "QuickHygiene", + "quick_intense": "QuickIntense", "quick_mw": "Quick MW", "quick_power_dry": "QuickPowerDry", "quick_power_wash": "QuickPowerWash", @@ -996,6 +1007,7 @@ "heating_up_phase": "Heating up phase", "hot_milk": "Hot milk", "hygiene": "Hygiene", + "hygiene_drying": "Hygiene drying", "interim_rinse": "Interim rinse", "keep_warm": "Keep warm", "keeping_warm": "Keeping warm", diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py index f7bc10e31d463e..b166a3a182ac74 100644 --- a/homeassistant/components/mikrotik/device_tracker.py +++ b/homeassistant/components/mikrotik/device_tracker.py @@ -5,7 +5,7 @@ from typing import Any from homeassistant.components.device_tracker import ( - DOMAIN as DEVICE_TRACKER, + DOMAIN as DEVICE_TRACKER_DOMAIN, ScannerEntity, ) from homeassistant.core import HomeAssistant, callback @@ -33,7 +33,7 @@ async def async_setup_entry( for entity in registry.entities.get_entries_for_config_entry_id( config_entry.entry_id ): - if entity.domain == DEVICE_TRACKER: + if entity.domain == DEVICE_TRACKER_DOMAIN: if ( entity.unique_id in coordinator.api.devices or entity.unique_id not in coordinator.api.all_devices diff --git a/homeassistant/components/mini_connected/__init__.py b/homeassistant/components/mini_connected/__init__.py deleted file mode 100644 index 4f0af581f58979..00000000000000 --- a/homeassistant/components/mini_connected/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Virtual integration: MINI Connected.""" diff --git a/homeassistant/components/mini_connected/manifest.json b/homeassistant/components/mini_connected/manifest.json deleted file mode 100644 index dfe9a64c9e02c4..00000000000000 --- a/homeassistant/components/mini_connected/manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "domain": "mini_connected", - "name": "MINI Connected", - "integration_type": "virtual", - "supported_by": "bmw_connected_drive" -} diff --git a/homeassistant/components/moat/__init__.py b/homeassistant/components/moat/__init__.py index 8ee2e294552617..1e8b0c06759f2c 100644 --- a/homeassistant/components/moat/__init__.py +++ b/homeassistant/components/moat/__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 MoatConfigEntry = ConfigEntry[PassiveBluetoothProcessorCoordinator] + -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: MoatConfigEntry) -> bool: """Set up Moat BLE device from a config entry.""" address = entry.unique_id assert address is not None data = MoatBluetoothDeviceData() - coordinator = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ( - PassiveBluetoothProcessorCoordinator( - hass, - _LOGGER, - address=address, - mode=BluetoothScanningMode.PASSIVE, - update_method=data.update, - ) + coordinator = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + address=address, + mode=BluetoothScanningMode.PASSIVE, + 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: MoatConfigEntry) -> 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/moat/sensor.py b/homeassistant/components/moat/sensor.py index e968577d78979f..5442f1bec2e7d1 100644 --- a/homeassistant/components/moat/sensor.py +++ b/homeassistant/components/moat/sensor.py @@ -4,12 +4,10 @@ from moat_ble import DeviceClass, DeviceKey, SensorUpdate, Units -from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, PassiveBluetoothEntityKey, - PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import ( @@ -28,7 +26,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 MoatConfigEntry SENSOR_DESCRIPTIONS = { (DeviceClass.TEMPERATURE, Units.TEMP_CELSIUS): SensorEntityDescription( @@ -104,13 +102,11 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: MoatConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Moat 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/mobile_app/entity.py b/homeassistant/components/mobile_app/entity.py index 89b207e29ead29..e97431baa13fb7 100644 --- a/homeassistant/components/mobile_app/entity.py +++ b/homeassistant/components/mobile_app/entity.py @@ -13,6 +13,7 @@ STATE_UNKNOWN, ) from homeassistant.core import State, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.restore_state import RestoreEntity @@ -95,7 +96,7 @@ async def async_restore_last_state(self, last_state: State) -> None: config[ATTR_SENSOR_ICON] = last_state.attributes[ATTR_ICON] @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device registry information for this entity.""" return device_info(self._registration) diff --git a/homeassistant/components/mobile_app/helpers.py b/homeassistant/components/mobile_app/helpers.py index 0ecfe207277183..776e98fc4bf953 100644 --- a/homeassistant/components/mobile_app/helpers.py +++ b/homeassistant/components/mobile_app/helpers.py @@ -193,7 +193,7 @@ def webhook_response( ) -def device_info(registration: dict) -> DeviceInfo: +def device_info(registration: Mapping[str, Any]) -> DeviceInfo: """Return the device info for this registration.""" return DeviceInfo( identifiers={(DOMAIN, registration[ATTR_DEVICE_ID])}, diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index a7d15e32853bd7..085c80afbebff3 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -120,6 +120,7 @@ async def async_send_message(self, message: str = "", **kwargs: Any) -> None: local_push_channels = self.hass.data[DOMAIN][DATA_PUSH_CHANNEL] + failed_targets = [] for target in targets: registration = self.hass.data[DOMAIN][DATA_CONFIG_ENTRIES][target].data @@ -134,12 +135,16 @@ async def async_send_message(self, message: str = "", **kwargs: Any) -> None: # Test if local push only. if ATTR_PUSH_URL not in registration[ATTR_APP_DATA]: - raise HomeAssistantError( - "Device not connected to local push notifications" - ) + failed_targets.append(target) + continue await self._async_send_remote_message_target(target, registration, data) + 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): """Send a message to a target.""" app_data = registration[ATTR_APP_DATA] diff --git a/homeassistant/components/modem_callerid/__init__.py b/homeassistant/components/modem_callerid/__init__.py index 886e33b714b965..addc063e39eae6 100644 --- a/homeassistant/components/modem_callerid/__init__.py +++ b/homeassistant/components/modem_callerid/__init__.py @@ -3,16 +3,20 @@ from phone_modem import PhoneModem from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE, Platform -from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_DEVICE, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DATA_KEY_API, DOMAIN, EXCEPTIONS +from .const import EXCEPTIONS PLATFORMS = [Platform.BUTTON, Platform.SENSOR] +type ModemCallerIdConfigEntry = ConfigEntry[PhoneModem] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry( + hass: HomeAssistant, entry: ModemCallerIdConfigEntry +) -> bool: """Set up Modem Caller ID from a config entry.""" device = entry.data[CONF_DEVICE] api = PhoneModem(device) @@ -21,17 +25,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except EXCEPTIONS as ex: raise ConfigEntryNotReady(f"Unable to open port: {device}") from ex - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {DATA_KEY_API: api} + entry.async_on_unload(api.close) + + async def _async_on_hass_stop(event: Event) -> None: + """HA is shutting down, close modem port.""" + api.close() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_on_hass_stop) + ) + + 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: ModemCallerIdConfigEntry +) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - api = hass.data[DOMAIN].pop(entry.entry_id)[DATA_KEY_API] - await api.close() - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/modem_callerid/button.py b/homeassistant/components/modem_callerid/button.py index 954a638818d632..5df2d67695f31e 100644 --- a/homeassistant/components/modem_callerid/button.py +++ b/homeassistant/components/modem_callerid/button.py @@ -5,26 +5,25 @@ from phone_modem import PhoneModem from homeassistant.components.button import ButtonEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_KEY_API, DOMAIN +from . import ModemCallerIdConfigEntry +from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ModemCallerIdConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Modem Caller ID sensor.""" - api = hass.data[DOMAIN][entry.entry_id][DATA_KEY_API] async_add_entities( [ PhoneModemButton( - api, + entry.runtime_data, entry.data[CONF_DEVICE], entry.entry_id, ) diff --git a/homeassistant/components/modem_callerid/const.py b/homeassistant/components/modem_callerid/const.py index d86d2648a101b0..a32433eb641ca0 100644 --- a/homeassistant/components/modem_callerid/const.py +++ b/homeassistant/components/modem_callerid/const.py @@ -5,7 +5,6 @@ from phone_modem import exceptions from serial import SerialException -DATA_KEY_API = "api" DEFAULT_NAME = "Phone Modem" DOMAIN = "modem_callerid" diff --git a/homeassistant/components/modem_callerid/sensor.py b/homeassistant/components/modem_callerid/sensor.py index db901511d5f3c7..d9d77dfac2f659 100644 --- a/homeassistant/components/modem_callerid/sensor.py +++ b/homeassistant/components/modem_callerid/sensor.py @@ -5,40 +5,30 @@ from phone_modem import PhoneModem from homeassistant.components.sensor import RestoreSensor -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EVENT_HOMEASSISTANT_STOP, STATE_IDLE -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.const import STATE_IDLE +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CID, DATA_KEY_API, DOMAIN +from . import ModemCallerIdConfigEntry +from .const import CID, DOMAIN async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ModemCallerIdConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Modem Caller ID sensor.""" - api = hass.data[DOMAIN][entry.entry_id][DATA_KEY_API] async_add_entities( [ ModemCalleridSensor( - api, + entry.runtime_data, entry.entry_id, ) ] ) - async def _async_on_hass_stop(event: Event) -> None: - """HA is shutting down, close modem port.""" - if hass.data[DOMAIN][entry.entry_id][DATA_KEY_API]: - await hass.data[DOMAIN][entry.entry_id][DATA_KEY_API].close() - - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_on_hass_stop) - ) - class ModemCalleridSensor(RestoreSensor): """Implementation of USB modem caller ID sensor.""" diff --git a/homeassistant/components/modern_forms/__init__.py b/homeassistant/components/modern_forms/__init__.py index 901e3f431a1cf6..80041f62c44638 100644 --- a/homeassistant/components/modern_forms/__init__.py +++ b/homeassistant/components/modern_forms/__init__.py @@ -8,12 +8,10 @@ from aiomodernforms import ModernFormsConnectionError, ModernFormsError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import ModernFormsDataUpdateCoordinator +from .coordinator import ModernFormsConfigEntry, ModernFormsDataUpdateCoordinator from .entity import ModernFormsDeviceEntity PLATFORMS = [ @@ -26,15 +24,14 @@ _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ModernFormsConfigEntry) -> bool: """Set up a Modern Forms device from a config entry.""" # Create Modern Forms instance for this entry coordinator = ModernFormsDataUpdateCoordinator(hass, entry) 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 for this device/entry. await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -42,17 +39,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: ModernFormsConfigEntry +) -> bool: """Unload Modern Forms config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - if unload_ok: - del hass.data[DOMAIN][entry.entry_id] - - if not hass.data[DOMAIN]: - del hass.data[DOMAIN] - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) def modernforms_exception_handler[ diff --git a/homeassistant/components/modern_forms/binary_sensor.py b/homeassistant/components/modern_forms/binary_sensor.py index 2bba85f54d7959..5bfad9b9ff4dc6 100644 --- a/homeassistant/components/modern_forms/binary_sensor.py +++ b/homeassistant/components/modern_forms/binary_sensor.py @@ -3,23 +3,22 @@ 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 homeassistant.util import dt as dt_util -from .const import CLEAR_TIMER, DOMAIN -from .coordinator import ModernFormsDataUpdateCoordinator +from .const import CLEAR_TIMER +from .coordinator import ModernFormsConfigEntry, ModernFormsDataUpdateCoordinator from .entity import ModernFormsDeviceEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ModernFormsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Modern Forms binary sensors.""" - coordinator: ModernFormsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data binary_sensors: list[ModernFormsBinarySensor] = [ ModernFormsFanSleepTimerActive(entry.entry_id, coordinator), diff --git a/homeassistant/components/modern_forms/coordinator.py b/homeassistant/components/modern_forms/coordinator.py index 203ba54380d3ac..492235cbe35314 100644 --- a/homeassistant/components/modern_forms/coordinator.py +++ b/homeassistant/components/modern_forms/coordinator.py @@ -20,6 +20,9 @@ _LOGGER = logging.getLogger(__name__) +type ModernFormsConfigEntry = ConfigEntry[ModernFormsDataUpdateCoordinator] + + class ModernFormsDataUpdateCoordinator(DataUpdateCoordinator[ModernFormsDeviceState]): """Class to manage fetching Modern Forms data from single endpoint.""" diff --git a/homeassistant/components/modern_forms/diagnostics.py b/homeassistant/components/modern_forms/diagnostics.py index 0011a7c3bab00c..6761adb7c9709d 100644 --- a/homeassistant/components/modern_forms/diagnostics.py +++ b/homeassistant/components/modern_forms/diagnostics.py @@ -3,27 +3,23 @@ from __future__ import annotations from dataclasses import asdict -from typing import TYPE_CHECKING, Any +from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import ModernFormsDataUpdateCoordinator +from .coordinator import ModernFormsConfigEntry REDACT_CONFIG = {CONF_MAC} REDACT_DEVICE_INFO = {"mac_address", "owner"} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: ModernFormsConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: ModernFormsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - if TYPE_CHECKING: - assert coordinator is not None + coordinator = entry.runtime_data return { "config_entry": async_redact_data(entry.as_dict(), REDACT_CONFIG), diff --git a/homeassistant/components/modern_forms/fan.py b/homeassistant/components/modern_forms/fan.py index 26c69b28a5cdb7..82f7fb111a23eb 100644 --- a/homeassistant/components/modern_forms/fan.py +++ b/homeassistant/components/modern_forms/fan.py @@ -8,7 +8,6 @@ import voluptuous as vol from homeassistant.components.fan import FanEntity, FanEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -22,26 +21,23 @@ from .const import ( ATTR_SLEEP_TIME, CLEAR_TIMER, - DOMAIN, OPT_ON, OPT_SPEED, SERVICE_CLEAR_FAN_SLEEP_TIMER, SERVICE_SET_FAN_SLEEP_TIMER, ) -from .coordinator import ModernFormsDataUpdateCoordinator +from .coordinator import ModernFormsConfigEntry, ModernFormsDataUpdateCoordinator from .entity import ModernFormsDeviceEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ModernFormsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Modern Forms platform from config entry.""" - coordinator: ModernFormsDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinator = config_entry.runtime_data platform = entity_platform.async_get_current_platform() diff --git a/homeassistant/components/modern_forms/light.py b/homeassistant/components/modern_forms/light.py index 6216efe3ff4a6c..213e14b31a9807 100644 --- a/homeassistant/components/modern_forms/light.py +++ b/homeassistant/components/modern_forms/light.py @@ -8,7 +8,6 @@ import voluptuous as vol from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -21,13 +20,12 @@ from .const import ( ATTR_SLEEP_TIME, CLEAR_TIMER, - DOMAIN, OPT_BRIGHTNESS, OPT_ON, SERVICE_CLEAR_LIGHT_SLEEP_TIMER, SERVICE_SET_LIGHT_SLEEP_TIMER, ) -from .coordinator import ModernFormsDataUpdateCoordinator +from .coordinator import ModernFormsConfigEntry, ModernFormsDataUpdateCoordinator from .entity import ModernFormsDeviceEntity BRIGHTNESS_RANGE = (1, 255) @@ -35,14 +33,12 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ModernFormsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Modern Forms platform from config entry.""" - coordinator: ModernFormsDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinator = config_entry.runtime_data # if no light unit installed no light entity if not coordinator.data.info.light_type: diff --git a/homeassistant/components/modern_forms/sensor.py b/homeassistant/components/modern_forms/sensor.py index aa7d163cfdc01e..75ba56a974f11f 100644 --- a/homeassistant/components/modern_forms/sensor.py +++ b/homeassistant/components/modern_forms/sensor.py @@ -5,24 +5,23 @@ from datetime import datetime 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 homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util -from .const import CLEAR_TIMER, DOMAIN -from .coordinator import ModernFormsDataUpdateCoordinator +from .const import CLEAR_TIMER +from .coordinator import ModernFormsConfigEntry, ModernFormsDataUpdateCoordinator from .entity import ModernFormsDeviceEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ModernFormsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Modern Forms sensor based on a config entry.""" - coordinator: ModernFormsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data sensors: list[ModernFormsSensor] = [ ModernFormsFanTimerRemainingTimeSensor(entry.entry_id, coordinator), diff --git a/homeassistant/components/modern_forms/switch.py b/homeassistant/components/modern_forms/switch.py index 89a5b779d74f35..003baa203dfb0e 100644 --- a/homeassistant/components/modern_forms/switch.py +++ b/homeassistant/components/modern_forms/switch.py @@ -5,23 +5,21 @@ from typing import Any 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 . import modernforms_exception_handler -from .const import DOMAIN -from .coordinator import ModernFormsDataUpdateCoordinator +from .coordinator import ModernFormsConfigEntry, ModernFormsDataUpdateCoordinator from .entity import ModernFormsDeviceEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ModernFormsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Modern Forms switch based on a config entry.""" - coordinator: ModernFormsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data switches = [ ModernFormsAwaySwitch(entry.entry_id, coordinator), diff --git a/homeassistant/components/moehlenhoff_alpha2/__init__.py b/homeassistant/components/moehlenhoff_alpha2/__init__.py index b015f9a09ddb85..1e4d0f7312627c 100644 --- a/homeassistant/components/moehlenhoff_alpha2/__init__.py +++ b/homeassistant/components/moehlenhoff_alpha2/__init__.py @@ -4,41 +4,33 @@ from moehlenhoff_alpha2 import Alpha2Base -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import Alpha2BaseCoordinator +from .coordinator import Alpha2BaseCoordinator, Alpha2ConfigEntry PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: Alpha2ConfigEntry) -> bool: """Set up a config entry.""" base = Alpha2Base(entry.data[CONF_HOST]) coordinator = Alpha2BaseCoordinator(hass, entry, base) 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: Alpha2ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok and entry.entry_id in hass.data[DOMAIN]: - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok - - -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: Alpha2ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py b/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py index a7479aef5e8924..d12c3c3df64778 100644 --- a/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py +++ b/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py @@ -4,24 +4,22 @@ 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 homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import Alpha2BaseCoordinator +from .coordinator import Alpha2BaseCoordinator, Alpha2ConfigEntry async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: Alpha2ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Alpha2 sensor entities from a config_entry.""" - coordinator: Alpha2BaseCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( Alpha2IODeviceBatterySensor(coordinator, io_device_id) @@ -51,7 +49,7 @@ def __init__(self, coordinator: Alpha2BaseCoordinator, io_device_id: str) -> Non ) @property - def is_on(self): + def is_on(self) -> bool: """Return the state of the sensor.""" # 0=empty, 1=weak, 2=good return self.coordinator.data["io_devices"][self.io_device_id]["BATTERY"] < 2 diff --git a/homeassistant/components/moehlenhoff_alpha2/button.py b/homeassistant/components/moehlenhoff_alpha2/button.py index 57f9d0e31a2e13..b338d66098dfe1 100644 --- a/homeassistant/components/moehlenhoff_alpha2/button.py +++ b/homeassistant/components/moehlenhoff_alpha2/button.py @@ -1,25 +1,23 @@ """Button entity to set the time of the Alpha2 base.""" 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 homeassistant.util import dt as dt_util -from .const import DOMAIN -from .coordinator import Alpha2BaseCoordinator +from .coordinator import Alpha2BaseCoordinator, Alpha2ConfigEntry async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: Alpha2ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Alpha2 button entities.""" - coordinator: Alpha2BaseCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities([Alpha2TimeSyncButton(coordinator, config_entry.entry_id)]) diff --git a/homeassistant/components/moehlenhoff_alpha2/climate.py b/homeassistant/components/moehlenhoff_alpha2/climate.py index 85d5939049ee62..4fb3c8584240c5 100644 --- a/homeassistant/components/moehlenhoff_alpha2/climate.py +++ b/homeassistant/components/moehlenhoff_alpha2/climate.py @@ -1,6 +1,5 @@ """Support for Alpha2 room control unit via Alpha2 base.""" -import logging from typing import Any from homeassistant.components.climate import ( @@ -9,26 +8,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 homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, PRESET_AUTO, PRESET_DAY, PRESET_NIGHT -from .coordinator import Alpha2BaseCoordinator - -_LOGGER = logging.getLogger(__name__) +from .const import PRESET_AUTO, PRESET_DAY, PRESET_NIGHT +from .coordinator import Alpha2BaseCoordinator, Alpha2ConfigEntry async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: Alpha2ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Alpha2Climate entities from a config_entry.""" - coordinator: Alpha2BaseCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( Alpha2Climate(coordinator, heat_area_id) diff --git a/homeassistant/components/moehlenhoff_alpha2/coordinator.py b/homeassistant/components/moehlenhoff_alpha2/coordinator.py index 50c2f9a5297ee3..5ea78fdf204276 100644 --- a/homeassistant/components/moehlenhoff_alpha2/coordinator.py +++ b/homeassistant/components/moehlenhoff_alpha2/coordinator.py @@ -17,14 +17,16 @@ UPDATE_INTERVAL = timedelta(seconds=60) +type Alpha2ConfigEntry = ConfigEntry[Alpha2BaseCoordinator] + class Alpha2BaseCoordinator(DataUpdateCoordinator[dict[str, dict]]): """Keep the base instance in one place and centralize the update.""" - config_entry: ConfigEntry + config_entry: Alpha2ConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, base: Alpha2Base + self, hass: HomeAssistant, config_entry: Alpha2ConfigEntry, base: Alpha2Base ) -> None: """Initialize Alpha2Base data updater.""" self.base = base diff --git a/homeassistant/components/moehlenhoff_alpha2/sensor.py b/homeassistant/components/moehlenhoff_alpha2/sensor.py index 306e80e54d383d..cee10a87d1eede 100644 --- a/homeassistant/components/moehlenhoff_alpha2/sensor.py +++ b/homeassistant/components/moehlenhoff_alpha2/sensor.py @@ -1,24 +1,22 @@ """Support for Alpha2 heat control valve opening sensors.""" from homeassistant.components.sensor import 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.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import Alpha2BaseCoordinator +from .coordinator import Alpha2BaseCoordinator, Alpha2ConfigEntry async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: Alpha2ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Alpha2 sensor entities from a config_entry.""" - coordinator: Alpha2BaseCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data # HEATCTRL attribute ACTOR_PERCENT is not available in older firmware versions async_add_entities( diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index 206e25433e27cf..7cdd3bd3111e3e 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -156,17 +156,15 @@ def __init__( """Initialize the sensor.""" self._attr_name = name self._attr_unique_id = unique_id - self._indoor_temp_sensor = indoor_temp_sensor - self._indoor_humidity_sensor = indoor_humidity_sensor - self._outdoor_temp_sensor = outdoor_temp_sensor + + self._entities = { + CONF_INDOOR_TEMP: indoor_temp_sensor, + CONF_OUTDOOR_TEMP: outdoor_temp_sensor, + CONF_INDOOR_HUMIDITY: indoor_humidity_sensor, + } self._calib_factor = calib_factor self._is_metric = is_metric self._attr_available = False - self._entities = { - indoor_temp_sensor, - indoor_humidity_sensor, - outdoor_temp_sensor, - } self._dewpoint: float | None = None self._indoor_temp: float | None = None self._outdoor_temp: float | None = None @@ -186,12 +184,7 @@ def async_start_preview( ) -> CALLBACK_TYPE: """Render a preview.""" # Abort early if there is no source entity_id's or calibration factor - if ( - not self._outdoor_temp_sensor - or not self._indoor_temp_sensor - or not self._indoor_humidity_sensor - or not self._calib_factor - ): + if not all((*self._entities.values(), self._calib_factor)): self._attr_available = False calculated_state = self._async_calculate_state() preview_callback(calculated_state.state, calculated_state.attributes) @@ -210,201 +203,149 @@ async def async_added_to_hass(self) -> None: def _async_setup_sensor(self) -> None: """Set up the sensor and start tracking state changes.""" - @callback - def mold_indicator_sensors_state_listener( - event: Event[EventStateChangedData], - ) -> None: - """Handle for state changes for dependent sensors.""" - new_state = event.data["new_state"] - old_state = event.data["old_state"] - entity = event.data["entity_id"] - _LOGGER.debug( - "Sensor state change for %s that had old state %s and new state %s", - entity, - old_state, - new_state, - ) - - if self._update_sensor(entity, old_state, new_state): - if self._preview_callback: - calculated_state = self._async_calculate_state() - self._preview_callback( - calculated_state.state, calculated_state.attributes - ) - # only write state to the state machine if we are not in preview mode - else: - self.async_schedule_update_ha_state(True) - - @callback - def mold_indicator_startup() -> None: - """Add listeners and get 1st state.""" - _LOGGER.debug("Startup for %s", self.entity_id) - + self.async_on_remove( async_track_state_change_event( - self.hass, list(self._entities), mold_indicator_sensors_state_listener + self.hass, + self._entities.values(), + self._async_mold_indicator_sensor_state_listener, ) + ) - # Read initial state - indoor_temp = self.hass.states.get(self._indoor_temp_sensor) - outdoor_temp = self.hass.states.get(self._outdoor_temp_sensor) - indoor_hum = self.hass.states.get(self._indoor_humidity_sensor) + # Replay current state of source entities + for entity_id in self._entities.values(): + state = self.hass.states.get(entity_id) + self._update_cached_values(entity_id, state) - schedule_update = self._update_sensor( - self._indoor_temp_sensor, None, indoor_temp - ) + self._recalculate() - schedule_update = ( - False - if not self._update_sensor( - self._outdoor_temp_sensor, None, outdoor_temp - ) - else schedule_update - ) + if self._preview_callback: + calculated_state = self._async_calculate_state() + self._preview_callback(calculated_state.state, calculated_state.attributes) - schedule_update = ( - False - if not self._update_sensor( - self._indoor_humidity_sensor, None, indoor_hum - ) - else schedule_update - ) + @callback + def _update_cached_values(self, entity_id: str, new_state: State | None) -> None: + """Update cached sensor values from a state.""" + if entity_id == self._entities[CONF_INDOOR_TEMP]: + self._indoor_temp = self._get_temperature_from_state(new_state) + elif entity_id == self._entities[CONF_OUTDOOR_TEMP]: + self._outdoor_temp = self._get_temperature_from_state(new_state) + elif entity_id == self._entities[CONF_INDOOR_HUMIDITY]: + self._indoor_hum = self._get_humidity_from_state(new_state) - if schedule_update and not self._preview_callback: - self.async_schedule_update_ha_state(True) - if self._preview_callback: - # re-calculate dewpoint and mold indicator - self._calc_dewpoint() - self._calc_moldindicator() - if self._attr_native_value is None: - self._attr_available = False - else: - self._attr_available = True - calculated_state = self._async_calculate_state() - self._preview_callback( - calculated_state.state, calculated_state.attributes - ) + @callback + def _async_mold_indicator_sensor_state_listener( + self, event: Event[EventStateChangedData] + ) -> None: + """Handle state changes for dependent sensors.""" + entity_id = event.data["entity_id"] + new_state = event.data["new_state"] - mold_indicator_startup() + _LOGGER.debug( + "Sensor state change for %s that had old state %s and new state %s", + entity_id, + event.data["old_state"], + new_state, + ) - def _update_sensor( - self, entity: str, old_state: State | None, new_state: State | None - ) -> bool: - """Update information based on new sensor states.""" - _LOGGER.debug("Sensor update for %s", entity) - if new_state is None: - return False + self._update_cached_values(entity_id, new_state) - # If old_state is not set and new state is unknown then it means - # that the sensor just started up - if old_state is None and new_state.state == STATE_UNKNOWN: - return False + self._recalculate() - if entity == self._indoor_temp_sensor: - self._indoor_temp = self._update_temp_sensor(new_state) - elif entity == self._outdoor_temp_sensor: - self._outdoor_temp = self._update_temp_sensor(new_state) - elif entity == self._indoor_humidity_sensor: - self._indoor_hum = self._update_hum_sensor(new_state) + if self._preview_callback: + calculated_state = self._async_calculate_state() + self._preview_callback(calculated_state.state, calculated_state.attributes) + # only write state to the state machine if we are not in preview mode + else: + self.async_write_ha_state() + + @callback + def _recalculate(self) -> None: + """Recalculate mold indicator from cached sensor values.""" + # Check if all sensors are available + if None in (self._indoor_temp, self._indoor_hum, self._outdoor_temp): + self._attr_available = False + self._attr_native_value = None + self._dewpoint = None + self._crit_temp = None + return - return True + # Calculate dewpoint and mold indicator + self._calc_dewpoint() + self._calc_moldindicator() + self._attr_available = self._attr_native_value is not None - @staticmethod - def _update_temp_sensor(state: State) -> float | None: - """Parse temperature sensor value.""" - _LOGGER.debug("Updating temp sensor with value %s", state.state) + def _get_value_from_state( + self, + state: State | None, + validator: Callable[[float, str | None], float | None], + ) -> float | None: + """Get and validate a sensor value from state.""" + if state is None: + return None - # Return an error if the sensor change its state to Unknown. if state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): _LOGGER.debug( - "Unable to parse temperature sensor %s with state: %s", + "Unable to get sensor %s, state: %s", state.entity_id, state.state, ) return None - if (temp := util.convert(state.state, float)) is None: - _LOGGER.error( - "Unable to parse temperature sensor %s with state: %s", + if (value := util.convert(state.state, float)) is None: + _LOGGER.debug( + "Unable to parse sensor value %s, state: %s to float", state.entity_id, state.state, ) return None - # convert to celsius if necessary - if ( - unit := state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - ) in UnitOfTemperature: - return TemperatureConverter.convert(temp, unit, UnitOfTemperature.CELSIUS) - _LOGGER.error( - "Temp sensor %s has unsupported unit: %s (allowed: %s, %s)", - state.entity_id, - unit, - UnitOfTemperature.CELSIUS, - UnitOfTemperature.FAHRENHEIT, - ) + return validator(value, state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) - return None + def _get_temperature_from_state(self, state: State | None) -> float | None: + """Get temperature value in Celsius from state.""" - @staticmethod - def _update_hum_sensor(state: State) -> float | None: - """Parse humidity sensor value.""" - _LOGGER.debug("Updating humidity sensor with value %s", state.state) + def validate_temperature(value: float, unit: str | None) -> float | None: + if TYPE_CHECKING: + assert state is not None - # Return an error if the sensor change its state to Unknown. - if state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): - _LOGGER.debug( - "Unable to parse humidity sensor %s, state: %s", - state.entity_id, - state.state, - ) - return None + if unit not in UnitOfTemperature: + _LOGGER.warning( + "Temp sensor %s has unsupported unit: %s (allowed: %s, %s)", + state.entity_id, + unit, + UnitOfTemperature.CELSIUS, + UnitOfTemperature.FAHRENHEIT, + ) + return None + return TemperatureConverter.convert(value, unit, UnitOfTemperature.CELSIUS) - if (hum := util.convert(state.state, float)) is None: - _LOGGER.error( - "Unable to parse humidity sensor %s, state: %s", - state.entity_id, - state.state, - ) - return None + return self._get_value_from_state(state, validate_temperature) - if (unit := state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) != PERCENTAGE: - _LOGGER.error( - "Humidity sensor %s has unsupported unit: %s (allowed: %s)", - state.entity_id, - unit, - PERCENTAGE, - ) - return None + def _get_humidity_from_state(self, state: State | None) -> float | None: + """Get humidity value from state.""" - if hum > 100 or hum < 0: - _LOGGER.error( - "Humidity sensor %s is out of range: %s (allowed: 0-100)", - state.entity_id, - hum, - ) - return None - - return hum + def validate_humidity(value: float, unit: str | None) -> float | None: + if TYPE_CHECKING: + assert state is not None - async def async_update(self) -> None: - """Calculate latest state.""" - _LOGGER.debug("Update state for %s", self.entity_id) - # check all sensors - if None in (self._indoor_temp, self._indoor_hum, self._outdoor_temp): - self._attr_available = False - self._dewpoint = None - self._crit_temp = None - return + if unit != PERCENTAGE: + _LOGGER.warning( + "Humidity sensor %s has unsupported unit: %s (allowed: %s)", + state.entity_id, + unit, + PERCENTAGE, + ) + return None + if not 0 <= value <= 100: + _LOGGER.warning( + "Humidity sensor %s is out of range: %s (allowed: 0-100)", + state.entity_id, + value, + ) + return None + return value - # re-calculate dewpoint and mold indicator - self._calc_dewpoint() - self._calc_moldindicator() - if self._attr_native_value is None: - self._attr_available = False - self._dewpoint = None - self._crit_temp = None - else: - self._attr_available = True + return self._get_value_from_state(state, validate_humidity) def _calc_dewpoint(self) -> None: """Calculate the dewpoint for the indoor air.""" @@ -425,7 +366,7 @@ def _calc_dewpoint(self) -> None: _LOGGER.debug("Dewpoint: %f %s", self._dewpoint, UnitOfTemperature.CELSIUS) def _calc_moldindicator(self) -> None: - """Calculate the humidity at the (cold) calibration point.""" + """Calculate the mold indicator value.""" if TYPE_CHECKING: assert self._outdoor_temp and self._indoor_temp and self._dewpoint @@ -436,7 +377,6 @@ def _calc_moldindicator(self) -> None: self._calib_factor, ) self._attr_native_value = None - self._attr_available = False self._crit_temp = None return @@ -464,13 +404,13 @@ def _calc_moldindicator(self) -> None: * 100.0 ) - # check bounds and format + # truncate humidity if crit_humidity > 100: - self._attr_native_value = "100" + self._attr_native_value = 100 elif crit_humidity < 0: - self._attr_native_value = "0" + self._attr_native_value = 0 else: - self._attr_native_value = f"{int(crit_humidity):d}" + self._attr_native_value = int(crit_humidity) _LOGGER.debug("Mold indicator humidity: %s", self.native_value) diff --git a/homeassistant/components/monoprice/__init__.py b/homeassistant/components/monoprice/__init__.py index 6e5c4c6181f3d6..1f5df2ca194c8a 100644 --- a/homeassistant/components/monoprice/__init__.py +++ b/homeassistant/components/monoprice/__init__.py @@ -1,8 +1,11 @@ """The Monoprice 6-Zone Amplifier integration.""" +from __future__ import annotations + +from dataclasses import dataclass import logging -from pymonoprice import get_monoprice +from pymonoprice import Monoprice, get_monoprice from serial import SerialException from homeassistant.config_entries import ConfigEntry @@ -10,14 +13,24 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import CONF_NOT_FIRST_RUN, DOMAIN, FIRST_RUN, MONOPRICE_OBJECT +from .const import CONF_NOT_FIRST_RUN PLATFORMS = [Platform.MEDIA_PLAYER] _LOGGER = logging.getLogger(__name__) +type MonopriceConfigEntry = ConfigEntry[MonopriceRuntimeData] + + +@dataclass +class MonopriceRuntimeData: + """Data stored in the config entry for a Monoprice entry.""" -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + client: Monoprice + first_run: bool + + +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] @@ -37,17 +50,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload(entry.add_update_listener(_update_listener)) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - MONOPRICE_OBJECT: monoprice, - FIRST_RUN: first_run, - } + entry.runtime_data = MonopriceRuntimeData( + client=monoprice, + first_run=first_run, + ) 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: MonopriceConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if not unload_ok: @@ -61,10 +74,7 @@ def _cleanup(monoprice) -> None: """ del monoprice - monoprice = hass.data[DOMAIN][entry.entry_id][MONOPRICE_OBJECT] - hass.data[DOMAIN].pop(entry.entry_id) - - await hass.async_add_executor_job(_cleanup, monoprice) + await hass.async_add_executor_job(_cleanup, entry.runtime_data.client) return True diff --git a/homeassistant/components/monoprice/const.py b/homeassistant/components/monoprice/const.py index 9dc9cad38319f0..290e625fddf924 100644 --- a/homeassistant/components/monoprice/const.py +++ b/homeassistant/components/monoprice/const.py @@ -15,6 +15,3 @@ SERVICE_SNAPSHOT = "snapshot" SERVICE_RESTORE = "restore" - -FIRST_RUN = "first_run" -MONOPRICE_OBJECT = "monoprice_object" diff --git a/homeassistant/components/monoprice/media_player.py b/homeassistant/components/monoprice/media_player.py index 734dbecd88b781..4561f29ba56612 100644 --- a/homeassistant/components/monoprice/media_player.py +++ b/homeassistant/components/monoprice/media_player.py @@ -11,21 +11,14 @@ MediaPlayerEntityFeature, MediaPlayerState, ) -from homeassistant.config_entries import ConfigEntry 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 .const import ( - CONF_SOURCES, - DOMAIN, - FIRST_RUN, - MONOPRICE_OBJECT, - SERVICE_RESTORE, - SERVICE_SNAPSHOT, -) +from . import MonopriceConfigEntry +from .const import CONF_SOURCES, DOMAIN, SERVICE_RESTORE, SERVICE_SNAPSHOT _LOGGER = logging.getLogger(__name__) @@ -57,13 +50,13 @@ def _get_sources(config_entry): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MonopriceConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Monoprice 6-zone amplifier platform.""" port = config_entry.data[CONF_PORT] - monoprice = hass.data[DOMAIN][config_entry.entry_id][MONOPRICE_OBJECT] + monoprice = config_entry.runtime_data.client sources = _get_sources(config_entry) @@ -77,8 +70,7 @@ async def async_setup_entry( ) # only call update before add if it's the first run so we can try to detect zones - first_run = hass.data[DOMAIN][config_entry.entry_id][FIRST_RUN] - async_add_entities(entities, first_run) + async_add_entities(entities, config_entry.runtime_data.first_run) platform = entity_platform.async_get_current_platform() @@ -128,6 +120,7 @@ class MonopriceZone(MediaPlayerEntity): ) _attr_has_entity_name = True _attr_name = None + _attr_volume_step = 1 / MAX_VOLUME def __init__(self, monoprice, sources, namespace, zone_id): """Initialize new zone.""" @@ -211,17 +204,3 @@ def mute_volume(self, mute: bool) -> None: def set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" self._monoprice.set_volume(self._zone_id, round(volume * MAX_VOLUME)) - - def volume_up(self) -> None: - """Volume up the media player.""" - if self.volume_level is None: - return - volume = round(self.volume_level * MAX_VOLUME) - self._monoprice.set_volume(self._zone_id, min(volume + 1, MAX_VOLUME)) - - def volume_down(self) -> None: - """Volume down media player.""" - if self.volume_level is None: - return - volume = round(self.volume_level * MAX_VOLUME) - self._monoprice.set_volume(self._zone_id, max(volume - 1, 0)) diff --git a/homeassistant/components/monzo/__init__.py b/homeassistant/components/monzo/__init__.py index ebac75721e5cba..e0aa3f3a8479ca 100644 --- a/homeassistant/components/monzo/__init__.py +++ b/homeassistant/components/monzo/__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.helpers.aiohttp_client import async_get_clientsession @@ -14,15 +13,14 @@ ) from .api import AuthenticatedMonzoAPI -from .const import DOMAIN -from .coordinator import MonzoCoordinator +from .coordinator import MonzoConfigEntry, MonzoCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.SENSOR] -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry(hass: HomeAssistant, entry: MonzoConfigEntry) -> bool: """Migrate entry.""" _LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version) @@ -39,7 +37,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: MonzoConfigEntry) -> bool: """Set up Monzo from a config entry.""" implementation = await async_get_config_entry_implementation(hass, entry) @@ -51,15 +49,12 @@ 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: MonzoConfigEntry) -> 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/monzo/coordinator.py b/homeassistant/components/monzo/coordinator.py index 06c751a23e05a4..68da9b256ad88f 100644 --- a/homeassistant/components/monzo/coordinator.py +++ b/homeassistant/components/monzo/coordinator.py @@ -1,5 +1,7 @@ """The Monzo integration.""" +from __future__ import annotations + from dataclasses import dataclass from datetime import timedelta import logging @@ -18,6 +20,8 @@ _LOGGER = logging.getLogger(__name__) +type MonzoConfigEntry = ConfigEntry[MonzoCoordinator] + @dataclass class MonzoData: @@ -30,10 +34,13 @@ class MonzoData: class MonzoCoordinator(DataUpdateCoordinator[MonzoData]): """Class to manage fetching Monzo data from the API.""" - config_entry: ConfigEntry + config_entry: MonzoConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, api: AuthenticatedMonzoAPI + self, + hass: HomeAssistant, + config_entry: MonzoConfigEntry, + api: AuthenticatedMonzoAPI, ) -> None: """Initialize.""" super().__init__( diff --git a/homeassistant/components/monzo/sensor.py b/homeassistant/components/monzo/sensor.py index e17f72dd4acc65..e7e644e93fe0b4 100644 --- a/homeassistant/components/monzo/sensor.py +++ b/homeassistant/components/monzo/sensor.py @@ -11,14 +11,11 @@ SensorEntity, SensorEntityDescription, ) -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 . import MonzoCoordinator -from .const import DOMAIN -from .coordinator import MonzoData +from .coordinator import MonzoConfigEntry, MonzoCoordinator, MonzoData from .entity import MonzoBaseEntity @@ -64,11 +61,11 @@ class MonzoSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MonzoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Defer sensor setup to the shared sensor module.""" - coordinator: MonzoCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data accounts = [ MonzoSensor( diff --git a/homeassistant/components/motion/__init__.py b/homeassistant/components/motion/__init__.py new file mode 100644 index 00000000000000..218a103eea4c24 --- /dev/null +++ b/homeassistant/components/motion/__init__.py @@ -0,0 +1,17 @@ +"""Integration for motion triggers.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +DOMAIN = "motion" +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/motion/condition.py b/homeassistant/components/motion/condition.py new file mode 100644 index 00000000000000..1cb87317f623f5 --- /dev/null +++ b/homeassistant/components/motion/condition.py @@ -0,0 +1,25 @@ +"""Provides conditions for motion.""" + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.condition import Condition, make_entity_state_condition + +_MOTION_DOMAIN_SPECS = { + BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.MOTION) +} + + +CONDITIONS: dict[str, type[Condition]] = { + "is_detected": make_entity_state_condition(_MOTION_DOMAIN_SPECS, STATE_ON), + "is_not_detected": make_entity_state_condition(_MOTION_DOMAIN_SPECS, STATE_OFF), +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the conditions for motion.""" + return CONDITIONS diff --git a/homeassistant/components/motion/conditions.yaml b/homeassistant/components/motion/conditions.yaml new file mode 100644 index 00000000000000..5b9ef602e79059 --- /dev/null +++ b/homeassistant/components/motion/conditions.yaml @@ -0,0 +1,24 @@ +.condition_common_fields: &condition_common_fields + behavior: + required: true + default: any + selector: + select: + translation_key: condition_behavior + options: + - all + - any + +is_detected: + fields: *condition_common_fields + target: + entity: + - domain: binary_sensor + device_class: motion + +is_not_detected: + fields: *condition_common_fields + target: + entity: + - domain: binary_sensor + device_class: motion diff --git a/homeassistant/components/motion/icons.json b/homeassistant/components/motion/icons.json new file mode 100644 index 00000000000000..ba439a43a9e7da --- /dev/null +++ b/homeassistant/components/motion/icons.json @@ -0,0 +1,18 @@ +{ + "conditions": { + "is_detected": { + "condition": "mdi:motion-sensor" + }, + "is_not_detected": { + "condition": "mdi:motion-sensor-off" + } + }, + "triggers": { + "cleared": { + "trigger": "mdi:motion-sensor-off" + }, + "detected": { + "trigger": "mdi:motion-sensor" + } + } +} diff --git a/homeassistant/components/motion/manifest.json b/homeassistant/components/motion/manifest.json new file mode 100644 index 00000000000000..62ac119f5f0cf2 --- /dev/null +++ b/homeassistant/components/motion/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "motion", + "name": "Motion", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/motion", + "integration_type": "system", + "quality_scale": "internal" +} diff --git a/homeassistant/components/motion/strings.json b/homeassistant/components/motion/strings.json new file mode 100644 index 00000000000000..cf810f0065cdbb --- /dev/null +++ b/homeassistant/components/motion/strings.json @@ -0,0 +1,68 @@ +{ + "common": { + "condition_behavior_description": "How the state should match on the targeted motion sensors.", + "condition_behavior_name": "Behavior", + "trigger_behavior_description": "The behavior of the targeted motion sensors to trigger on.", + "trigger_behavior_name": "Behavior" + }, + "conditions": { + "is_detected": { + "description": "Tests if one or more motion sensors are detecting motion.", + "fields": { + "behavior": { + "description": "[%key:component::motion::common::condition_behavior_description%]", + "name": "[%key:component::motion::common::condition_behavior_name%]" + } + }, + "name": "Motion is detected" + }, + "is_not_detected": { + "description": "Tests if one or more motion sensors are not detecting motion.", + "fields": { + "behavior": { + "description": "[%key:component::motion::common::condition_behavior_description%]", + "name": "[%key:component::motion::common::condition_behavior_name%]" + } + }, + "name": "Motion is not detected" + } + }, + "selector": { + "condition_behavior": { + "options": { + "all": "All", + "any": "Any" + } + }, + "trigger_behavior": { + "options": { + "any": "Any", + "first": "First", + "last": "Last" + } + } + }, + "title": "Motion", + "triggers": { + "cleared": { + "description": "Triggers after one or more motion sensors stop detecting motion.", + "fields": { + "behavior": { + "description": "[%key:component::motion::common::trigger_behavior_description%]", + "name": "[%key:component::motion::common::trigger_behavior_name%]" + } + }, + "name": "Motion cleared" + }, + "detected": { + "description": "Triggers after one or more motion sensors start detecting motion.", + "fields": { + "behavior": { + "description": "[%key:component::motion::common::trigger_behavior_description%]", + "name": "[%key:component::motion::common::trigger_behavior_name%]" + } + }, + "name": "Motion detected" + } + } +} diff --git a/homeassistant/components/motion/trigger.py b/homeassistant/components/motion/trigger.py new file mode 100644 index 00000000000000..5438a7ddbb97c9 --- /dev/null +++ b/homeassistant/components/motion/trigger.py @@ -0,0 +1,24 @@ +"""Provides triggers for motion.""" + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger + +_MOTION_DOMAIN_SPECS = { + BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.MOTION) +} + +TRIGGERS: dict[str, type[Trigger]] = { + "detected": make_entity_target_state_trigger(_MOTION_DOMAIN_SPECS, STATE_ON), + "cleared": make_entity_target_state_trigger(_MOTION_DOMAIN_SPECS, STATE_OFF), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for motion.""" + return TRIGGERS diff --git a/homeassistant/components/motion/triggers.yaml b/homeassistant/components/motion/triggers.yaml new file mode 100644 index 00000000000000..1be6124ed17b30 --- /dev/null +++ b/homeassistant/components/motion/triggers.yaml @@ -0,0 +1,25 @@ +.trigger_common_fields: &trigger_common_fields + behavior: + required: true + default: any + selector: + select: + translation_key: trigger_behavior + options: + - first + - last + - any + +detected: + fields: *trigger_common_fields + target: + entity: + - domain: binary_sensor + device_class: motion + +cleared: + fields: *trigger_common_fields + target: + entity: + - domain: binary_sensor + device_class: motion diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index 8af091b90b2cd9..f1351af8bc21d0 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -268,6 +268,26 @@ class MotionTiltDevice(MotionPositionDevice): _restore_tilt = True + @property + def supported_features(self) -> CoverEntityFeature: + """Flag supported features.""" + supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT + ) + + if self.current_cover_position is not None: + supported_features |= CoverEntityFeature.SET_POSITION + + if self.current_cover_tilt_position is not None: + supported_features |= CoverEntityFeature.SET_TILT_POSITION + + return supported_features + @property def current_cover_tilt_position(self) -> int | None: """Return current angle of cover. @@ -287,17 +307,25 @@ def is_closed(self) -> bool | None: async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" - async with self._api_lock: - await self.hass.async_add_executor_job(self._blind.Set_angle, 0) + if self.current_cover_tilt_position is not None: + async with self._api_lock: + await self.hass.async_add_executor_job(self._blind.Set_angle, 0) - await self.async_request_position_till_stop() + await self.async_request_position_till_stop() + else: + async with self._api_lock: + await self.hass.async_add_executor_job(self._blind.Jog_up) async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" - async with self._api_lock: - await self.hass.async_add_executor_job(self._blind.Set_angle, 180) + if self.current_cover_tilt_position is not None: + async with self._api_lock: + await self.hass.async_add_executor_job(self._blind.Set_angle, 180) - await self.async_request_position_till_stop() + await self.async_request_position_till_stop() + else: + async with self._api_lock: + await self.hass.async_add_executor_job(self._blind.Jog_down) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" diff --git a/homeassistant/components/motion_blinds/sensor.py b/homeassistant/components/motion_blinds/sensor.py index 60d283aa0b6332..eac89eccdd205f 100644 --- a/homeassistant/components/motion_blinds/sensor.py +++ b/homeassistant/components/motion_blinds/sensor.py @@ -1,5 +1,7 @@ """Support for Motionblinds sensors.""" +from typing import Any + from motionblinds import DEVICE_TYPES_WIFI from motionblinds.motion_blinds import DEVICE_TYPE_TDBU @@ -68,7 +70,7 @@ def native_value(self): return self._blind.battery_level @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return device specific state attributes.""" return {ATTR_BATTERY_VOLTAGE: self._blind.battery_voltage} @@ -92,7 +94,7 @@ def native_value(self): return self._blind.battery_level[self._motor[0]] @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return device specific state attributes.""" attributes = {} if self._blind.battery_voltage is not None: diff --git a/homeassistant/components/motionblinds_ble/__init__.py b/homeassistant/components/motionblinds_ble/__init__.py index 76ceac1097c411..a278a19046f03e 100644 --- a/homeassistant/components/motionblinds_ble/__init__.py +++ b/homeassistant/components/motionblinds_ble/__init__.py @@ -43,6 +43,8 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) +type MotionConfigEntry = ConfigEntry[MotionDevice] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Motionblinds Bluetooth integration.""" @@ -56,7 +58,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: MotionConfigEntry) -> bool: """Set up Motionblinds Bluetooth device from a config entry.""" _LOGGER.debug("(%s) Setting up device", entry.data[CONF_MAC_CODE]) @@ -95,11 +97,11 @@ def async_update_ble_device( ) ) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = device - # Register OptionsFlow update listener entry.async_on_unload(entry.add_update_listener(options_update_listener)) + entry.runtime_data = device + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # Apply options @@ -112,7 +114,9 @@ def async_update_ble_device( return True -async def options_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def options_update_listener( + hass: HomeAssistant, entry: MotionConfigEntry +) -> None: """Handle options update.""" _LOGGER.debug( "(%s) Updated device options: %s", entry.data[CONF_MAC_CODE], entry.options @@ -120,10 +124,10 @@ async def options_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> No await apply_options(hass, entry) -async def apply_options(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def apply_options(hass: HomeAssistant, entry: MotionConfigEntry) -> None: """Apply the options from the OptionsFlow.""" - device: MotionDevice = hass.data[DOMAIN][entry.entry_id] + device = entry.runtime_data disconnect_time: float | None = entry.options.get(OPTION_DISCONNECT_TIME, None) permanent_connection: bool = entry.options.get(OPTION_PERMANENT_CONNECTION, False) @@ -131,10 +135,7 @@ async def apply_options(hass: HomeAssistant, entry: ConfigEntry) -> None: await device.set_permanent_connection(permanent_connection) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: MotionConfigEntry) -> bool: """Unload Motionblinds Bluetooth device from 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/motionblinds_ble/button.py b/homeassistant/components/motionblinds_ble/button.py index 12fb6c7a513dc2..22fc5a2e329186 100644 --- a/homeassistant/components/motionblinds_ble/button.py +++ b/homeassistant/components/motionblinds_ble/button.py @@ -10,12 +10,12 @@ from motionblindsble.device import MotionDevice from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ATTR_CONNECT, ATTR_DISCONNECT, ATTR_FAVORITE, CONF_MAC_CODE, DOMAIN +from . import MotionConfigEntry +from .const import ATTR_CONNECT, ATTR_DISCONNECT, ATTR_FAVORITE, CONF_MAC_CODE from .entity import MotionblindsBLEEntity _LOGGER = logging.getLogger(__name__) @@ -54,12 +54,12 @@ class MotionblindsBLEButtonEntityDescription(ButtonEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MotionConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up button entities based on a config entry.""" - device: MotionDevice = hass.data[DOMAIN][entry.entry_id] + device = entry.runtime_data async_add_entities( MotionblindsBLEButtonEntity( diff --git a/homeassistant/components/motionblinds_ble/config_flow.py b/homeassistant/components/motionblinds_ble/config_flow.py index 30417c62c65381..a147b6f71d2987 100644 --- a/homeassistant/components/motionblinds_ble/config_flow.py +++ b/homeassistant/components/motionblinds_ble/config_flow.py @@ -12,12 +12,7 @@ from homeassistant.components import bluetooth from homeassistant.components.bluetooth import BluetoothServiceInfoBleak -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_ADDRESS from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError @@ -27,6 +22,7 @@ SelectSelectorMode, ) +from . import MotionConfigEntry from .const import ( CONF_BLIND_TYPE, CONF_LOCAL_NAME, @@ -185,7 +181,7 @@ async def async_discover_motionblind(self, mac_code: str) -> None: @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: MotionConfigEntry, ) -> OptionsFlow: """Create the options flow.""" return OptionsFlowHandler() diff --git a/homeassistant/components/motionblinds_ble/cover.py b/homeassistant/components/motionblinds_ble/cover.py index beaee8598b5c85..a96427aabbd12e 100644 --- a/homeassistant/components/motionblinds_ble/cover.py +++ b/homeassistant/components/motionblinds_ble/cover.py @@ -7,7 +7,6 @@ from typing import Any from motionblindsble.const import MotionBlindType, MotionRunningType -from motionblindsble.device import MotionDevice from homeassistant.components.cover import ( ATTR_POSITION, @@ -17,11 +16,11 @@ CoverEntityDescription, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONF_BLIND_TYPE, CONF_MAC_CODE, DOMAIN, ICON_VERTICAL_BLIND +from . import MotionConfigEntry +from .const import CONF_BLIND_TYPE, CONF_MAC_CODE, ICON_VERTICAL_BLIND from .entity import MotionblindsBLEEntity _LOGGER = logging.getLogger(__name__) @@ -62,7 +61,7 @@ class MotionblindsBLECoverEntityDescription(CoverEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MotionConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up cover entity based on a config entry.""" @@ -70,7 +69,7 @@ async def async_setup_entry( cover_class: type[MotionblindsBLECoverEntity] = BLIND_TYPE_TO_CLASS[ entry.data[CONF_BLIND_TYPE].upper() ] - device: MotionDevice = hass.data[DOMAIN][entry.entry_id] + device = entry.runtime_data entity_description: MotionblindsBLECoverEntityDescription = ( BLIND_TYPE_TO_ENTITY_DESCRIPTION[entry.data[CONF_BLIND_TYPE].upper()] ) diff --git a/homeassistant/components/motionblinds_ble/diagnostics.py b/homeassistant/components/motionblinds_ble/diagnostics.py index c76bef7c2f88df..d693c3358f4027 100644 --- a/homeassistant/components/motionblinds_ble/diagnostics.py +++ b/homeassistant/components/motionblinds_ble/diagnostics.py @@ -5,14 +5,11 @@ from collections.abc import Iterable from typing import Any -from motionblindsble.device import MotionDevice - 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 DOMAIN +from . import MotionConfigEntry CONF_TITLE = "title" @@ -24,10 +21,10 @@ async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: MotionConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - device: MotionDevice = hass.data[DOMAIN][entry.entry_id] + device = entry.runtime_data return async_redact_data( { diff --git a/homeassistant/components/motionblinds_ble/entity.py b/homeassistant/components/motionblinds_ble/entity.py index 0b8171e7acd88d..7c2e68f9f721ff 100644 --- a/homeassistant/components/motionblinds_ble/entity.py +++ b/homeassistant/components/motionblinds_ble/entity.py @@ -5,11 +5,11 @@ from motionblindsble.const import MotionBlindType from motionblindsble.device import MotionDevice -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription +from . import MotionConfigEntry from .const import CONF_BLIND_TYPE, CONF_MAC_CODE, MANUFACTURER _LOGGER = logging.getLogger(__name__) @@ -21,13 +21,10 @@ class MotionblindsBLEEntity(Entity): _attr_has_entity_name = True _attr_should_poll = False - device: MotionDevice - entry: ConfigEntry - def __init__( self, device: MotionDevice, - entry: ConfigEntry, + entry: MotionConfigEntry, entity_description: EntityDescription, unique_id_suffix: str | None = None, ) -> None: diff --git a/homeassistant/components/motionblinds_ble/select.py b/homeassistant/components/motionblinds_ble/select.py index 976f51a0a0f99b..a3d7c378798c24 100644 --- a/homeassistant/components/motionblinds_ble/select.py +++ b/homeassistant/components/motionblinds_ble/select.py @@ -8,12 +8,12 @@ from motionblindsble.device import MotionDevice from homeassistant.components.select import SelectEntity, SelectEntityDescription -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 ATTR_SPEED, CONF_MAC_CODE, DOMAIN +from . import MotionConfigEntry +from .const import ATTR_SPEED, CONF_MAC_CODE from .entity import MotionblindsBLEEntity _LOGGER = logging.getLogger(__name__) @@ -33,12 +33,12 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MotionConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up select entities based on a config entry.""" - device: MotionDevice = hass.data[DOMAIN][entry.entry_id] + device = entry.runtime_data if device.blind_type not in {MotionBlindType.CURTAIN, MotionBlindType.VERTICAL}: async_add_entities([SpeedSelect(device, entry, SELECT_TYPES[ATTR_SPEED])]) @@ -50,7 +50,7 @@ class SpeedSelect(MotionblindsBLEEntity, SelectEntity): def __init__( self, device: MotionDevice, - entry: ConfigEntry, + entry: MotionConfigEntry, entity_description: SelectEntityDescription, ) -> None: """Initialize the speed select entity.""" diff --git a/homeassistant/components/motionblinds_ble/sensor.py b/homeassistant/components/motionblinds_ble/sensor.py index 7a6dcb493ebbdb..c90998a0c4a87c 100644 --- a/homeassistant/components/motionblinds_ble/sensor.py +++ b/homeassistant/components/motionblinds_ble/sensor.py @@ -20,7 +20,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -30,13 +29,13 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType +from . import MotionConfigEntry from .const import ( ATTR_BATTERY, ATTR_CALIBRATION, ATTR_CONNECTION, ATTR_SIGNAL_STRENGTH, CONF_MAC_CODE, - DOMAIN, ) from .entity import MotionblindsBLEEntity @@ -94,12 +93,12 @@ class MotionblindsBLESensorEntityDescription[_T](SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MotionConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensor entities based on a config entry.""" - device: MotionDevice = hass.data[DOMAIN][entry.entry_id] + device = entry.runtime_data entities: list[SensorEntity] = [ MotionblindsBLESensorEntity(device, entry, description) @@ -118,7 +117,7 @@ class MotionblindsBLESensorEntity[_T](MotionblindsBLEEntity, SensorEntity): def __init__( self, device: MotionDevice, - entry: ConfigEntry, + entry: MotionConfigEntry, entity_description: MotionblindsBLESensorEntityDescription[_T], ) -> None: """Initialize the sensor entity.""" @@ -149,7 +148,7 @@ class BatterySensor(MotionblindsBLEEntity, SensorEntity): def __init__( self, device: MotionDevice, - entry: ConfigEntry, + entry: MotionConfigEntry, ) -> None: """Initialize the sensor entity.""" entity_description = SensorEntityDescription( diff --git a/homeassistant/components/motioneye/__init__.py b/homeassistant/components/motioneye/__init__.py index 9984175fde9733..5f3799abb1f90e 100644 --- a/homeassistant/components/motioneye/__init__.py +++ b/homeassistant/components/motioneye/__init__.py @@ -56,20 +56,16 @@ async_dispatcher_send, ) from homeassistant.helpers.network import NoURLAvailableError, get_url -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( ATTR_EVENT_TYPE, ATTR_WEBHOOK_ID, CONF_ADMIN_PASSWORD, CONF_ADMIN_USERNAME, - CONF_CLIENT, - CONF_COORDINATOR, CONF_SURVEILLANCE_PASSWORD, CONF_SURVEILLANCE_USERNAME, CONF_WEBHOOK_SET, CONF_WEBHOOK_SET_OVERWRITE, - DEFAULT_SCAN_INTERVAL, DEFAULT_WEBHOOK_SET, DEFAULT_WEBHOOK_SET_OVERWRITE, DOMAIN, @@ -84,6 +80,7 @@ WEB_HOOK_SENTINEL_KEY, WEB_HOOK_SENTINEL_VALUE, ) +from .coordinator import MotionEyeUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [CAMERA_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN] @@ -308,24 +305,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, DOMAIN, "motionEye", entry.data[CONF_WEBHOOK_ID], handle_webhook ) - async def async_update_data() -> dict[str, Any] | None: - try: - return await client.async_get_cameras() - except MotionEyeClientError as exc: - raise UpdateFailed("Error communicating with API") from exc - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name=DOMAIN, - update_method=async_update_data, - update_interval=DEFAULT_SCAN_INTERVAL, - ) - hass.data[DOMAIN][entry.entry_id] = { - CONF_CLIENT: client, - CONF_COORDINATOR: coordinator, - } + coordinator = MotionEyeUpdateCoordinator(hass, entry, client) + hass.data[DOMAIN][entry.entry_id] = coordinator current_cameras: set[tuple[str, str]] = set() device_registry = dr.async_get(hass) @@ -387,8 +368,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - config_data = hass.data[DOMAIN].pop(entry.entry_id) - await config_data[CONF_CLIENT].async_client_close() + coordinator = hass.data[DOMAIN].pop(entry.entry_id) + await coordinator.client.async_client_close() return unload_ok @@ -460,9 +441,8 @@ def _get_media_event_data( if not config_entry_id or config_entry_id not in hass.data[DOMAIN]: return {} - config_entry_data = hass.data[DOMAIN][config_entry_id] - client = config_entry_data[CONF_CLIENT] - coordinator = config_entry_data[CONF_COORDINATOR] + coordinator = hass.data[DOMAIN][config_entry_id] + client = coordinator.client for identifier in device.identifiers: data = split_motioneye_device_identifier(identifier) diff --git a/homeassistant/components/motioneye/camera.py b/homeassistant/components/motioneye/camera.py index adf380bf9ebe22..65baa163e0a715 100644 --- a/homeassistant/components/motioneye/camera.py +++ b/homeassistant/components/motioneye/camera.py @@ -43,13 +43,10 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import get_camera_from_cameras, is_acceptable_camera, listen_for_new_cameras from .const import ( CONF_ACTION, - CONF_CLIENT, - CONF_COORDINATOR, CONF_STREAM_URL_TEMPLATE, CONF_SURVEILLANCE_PASSWORD, CONF_SURVEILLANCE_USERNAME, @@ -60,6 +57,7 @@ SERVICE_SNAPSHOT, TYPE_MOTIONEYE_MJPEG_CAMERA, ) +from .coordinator import MotionEyeUpdateCoordinator from .entity import MotionEyeEntity PLATFORMS = [Platform.CAMERA] @@ -98,7 +96,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up motionEye from a config entry.""" - entry_data = hass.data[DOMAIN][entry.entry_id] + coordinator = hass.data[DOMAIN][entry.entry_id] @callback def camera_add(camera: dict[str, Any]) -> None: @@ -112,8 +110,8 @@ def camera_add(camera: dict[str, Any]) -> None: ), entry.data.get(CONF_SURVEILLANCE_PASSWORD, ""), camera, - entry_data[CONF_CLIENT], - entry_data[CONF_COORDINATOR], + coordinator.client, + coordinator, entry.options, ) ] @@ -153,7 +151,7 @@ def __init__( password: str, camera: dict[str, Any], client: MotionEyeClient, - coordinator: DataUpdateCoordinator, + coordinator: MotionEyeUpdateCoordinator, options: Mapping[str, str], ) -> None: """Initialize a MJPEG camera.""" diff --git a/homeassistant/components/motioneye/const.py b/homeassistant/components/motioneye/const.py index 15a856035e1cdd..14ecde90ea25f5 100644 --- a/homeassistant/components/motioneye/const.py +++ b/homeassistant/components/motioneye/const.py @@ -30,8 +30,6 @@ ATTR_WEBHOOK_ID: Final = "webhook_id" CONF_ACTION: Final = "action" -CONF_CLIENT: Final = "client" -CONF_COORDINATOR: Final = "coordinator" CONF_ADMIN_PASSWORD: Final = "admin_password" CONF_ADMIN_USERNAME: Final = "admin_username" CONF_STREAM_URL_TEMPLATE: Final = "stream_url_template" diff --git a/homeassistant/components/motioneye/coordinator.py b/homeassistant/components/motioneye/coordinator.py new file mode 100644 index 00000000000000..6e330d5d27bb66 --- /dev/null +++ b/homeassistant/components/motioneye/coordinator.py @@ -0,0 +1,41 @@ +"""Coordinator for the motionEye integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from motioneye_client.client import MotionEyeClient, MotionEyeClientError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class MotionEyeUpdateCoordinator(DataUpdateCoordinator[dict[str, Any] | None]): + """Coordinator for motionEye data.""" + + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, client: MotionEyeClient + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + config_entry=entry, + update_interval=DEFAULT_SCAN_INTERVAL, + ) + self.client = client + + async def _async_update_data(self) -> dict[str, Any] | None: + try: + return await self.client.async_get_cameras() + except MotionEyeClientError as exc: + raise UpdateFailed("Error communicating with API") from exc diff --git a/homeassistant/components/motioneye/entity.py b/homeassistant/components/motioneye/entity.py index e279533f0807fc..e3c5a19d8fa3eb 100644 --- a/homeassistant/components/motioneye/entity.py +++ b/homeassistant/components/motioneye/entity.py @@ -10,12 +10,10 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import get_motioneye_device_identifier +from .coordinator import MotionEyeUpdateCoordinator def get_motioneye_entity_unique_id( @@ -25,7 +23,7 @@ def get_motioneye_entity_unique_id( return f"{config_entry_id}_{camera_id}_{entity_type}" -class MotionEyeEntity(CoordinatorEntity): +class MotionEyeEntity(CoordinatorEntity[MotionEyeUpdateCoordinator]): """Base class for motionEye entities.""" _attr_has_entity_name = True @@ -36,7 +34,7 @@ def __init__( type_name: str, camera: dict[str, Any], client: MotionEyeClient, - coordinator: DataUpdateCoordinator, + coordinator: MotionEyeUpdateCoordinator, options: Mapping[str, Any], entity_description: EntityDescription | None = None, ) -> None: diff --git a/homeassistant/components/motioneye/media_source.py b/homeassistant/components/motioneye/media_source.py index 7a5ed6646d5d56..52d4ca04530639 100644 --- a/homeassistant/components/motioneye/media_source.py +++ b/homeassistant/components/motioneye/media_source.py @@ -22,7 +22,7 @@ from homeassistant.helpers import device_registry as dr from . import get_media_url, split_motioneye_device_identifier -from .const import CONF_CLIENT, DOMAIN +from .const import DOMAIN MIME_TYPE_MAP = { "movies": "video/mp4", @@ -74,7 +74,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][CONF_CLIENT], + self.hass.data[DOMAIN][config.entry_id].client, self._get_camera_id_or_raise(config, device), self._get_path_or_raise(path), kind == "images", @@ -276,7 +276,7 @@ async def _build_media_path( base.children = [] - client = self.hass.data[DOMAIN][config.entry_id][CONF_CLIENT] + client = self.hass.data[DOMAIN][config.entry_id].client camera_id = self._get_camera_id_or_raise(config, device) if kind == "movies": diff --git a/homeassistant/components/motioneye/sensor.py b/homeassistant/components/motioneye/sensor.py index c8d05c6bb4d6de..be3644451015bb 100644 --- a/homeassistant/components/motioneye/sensor.py +++ b/homeassistant/components/motioneye/sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Mapping -import logging from typing import Any from motioneye_client.client import MotionEyeClient @@ -14,14 +13,12 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import get_camera_from_cameras, listen_for_new_cameras -from .const import CONF_CLIENT, CONF_COORDINATOR, DOMAIN, TYPE_MOTIONEYE_ACTION_SENSOR +from .const import DOMAIN, TYPE_MOTIONEYE_ACTION_SENSOR +from .coordinator import MotionEyeUpdateCoordinator from .entity import MotionEyeEntity -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry( hass: HomeAssistant, @@ -29,7 +26,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up motionEye from a config entry.""" - entry_data = hass.data[DOMAIN][entry.entry_id] + coordinator = hass.data[DOMAIN][entry.entry_id] @callback def camera_add(camera: dict[str, Any]) -> None: @@ -39,8 +36,8 @@ def camera_add(camera: dict[str, Any]) -> None: MotionEyeActionSensor( entry.entry_id, camera, - entry_data[CONF_CLIENT], - entry_data[CONF_COORDINATOR], + coordinator.client, + coordinator, entry.options, ) ] @@ -59,7 +56,7 @@ def __init__( config_entry_id: str, camera: dict[str, Any], client: MotionEyeClient, - coordinator: DataUpdateCoordinator, + coordinator: MotionEyeUpdateCoordinator, options: Mapping[str, str], ) -> None: """Initialize an action sensor.""" diff --git a/homeassistant/components/motioneye/switch.py b/homeassistant/components/motioneye/switch.py index afa0b9481d1a31..4acaf54ae2077e 100644 --- a/homeassistant/components/motioneye/switch.py +++ b/homeassistant/components/motioneye/switch.py @@ -20,10 +20,10 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import get_camera_from_cameras, listen_for_new_cameras -from .const import CONF_CLIENT, CONF_COORDINATOR, DOMAIN, TYPE_MOTIONEYE_SWITCH_BASE +from .const import DOMAIN, TYPE_MOTIONEYE_SWITCH_BASE +from .coordinator import MotionEyeUpdateCoordinator from .entity import MotionEyeEntity MOTIONEYE_SWITCHES = [ @@ -72,7 +72,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up motionEye from a config entry.""" - entry_data = hass.data[DOMAIN][entry.entry_id] + coordinator = hass.data[DOMAIN][entry.entry_id] @callback def camera_add(camera: dict[str, Any]) -> None: @@ -82,8 +82,8 @@ def camera_add(camera: dict[str, Any]) -> None: MotionEyeSwitch( entry.entry_id, camera, - entry_data[CONF_CLIENT], - entry_data[CONF_COORDINATOR], + coordinator.client, + coordinator, entry.options, entity_description, ) @@ -102,7 +102,7 @@ def __init__( config_entry_id: str, camera: dict[str, Any], client: MotionEyeClient, - coordinator: DataUpdateCoordinator, + coordinator: MotionEyeUpdateCoordinator, options: Mapping[str, str], entity_description: SwitchEntityDescription, ) -> None: diff --git a/homeassistant/components/mpd/media_player.py b/homeassistant/components/mpd/media_player.py index 14b69e941b7d0e..8a33e6ff6c2f40 100644 --- a/homeassistant/components/mpd/media_player.py +++ b/homeassistant/components/mpd/media_player.py @@ -93,6 +93,7 @@ class MpdDevice(MediaPlayerEntity): _attr_media_content_type = MediaType.MUSIC _attr_has_entity_name = True _attr_name = None + _attr_volume_step = 0.05 def __init__( self, server: str, port: int, password: str | None, unique_id: str @@ -393,24 +394,6 @@ async def async_set_volume_level(self, volume: float) -> None: if "volume" in self._status: await self._client.setvol(int(volume * 100)) - async def async_volume_up(self) -> None: - """Service to send the MPD the command for volume up.""" - async with self.connection(): - if "volume" in self._status: - current_volume = int(self._status["volume"]) - - if current_volume <= 100: - self._client.setvol(current_volume + 5) - - async def async_volume_down(self) -> None: - """Service to send the MPD the command for volume down.""" - async with self.connection(): - if "volume" in self._status: - current_volume = int(self._status["volume"]) - - if current_volume >= 0: - await self._client.setvol(current_volume - 5) - async def async_media_play(self) -> None: """Service to send the MPD the command for play/pause.""" async with self.connection(): diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 89857efc149264..9892384e804da1 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -18,8 +18,9 @@ "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": "color_mode", "clrm_stat_t": "color_mode_state_topic", "clrm_val_tpl": "color_mode_value_template", "clr_temp_cmd_t": "color_temp_command_topic", @@ -73,6 +74,7 @@ "fan_mode_stat_t": "fan_mode_state_topic", "frc_upd": "force_update", "g_tpl": "green_template", + "grp": "group", "hs_cmd_t": "hs_command_topic", "hs_cmd_tpl": "hs_command_template", "hs_stat_t": "hs_state_topic", @@ -108,7 +110,6 @@ "modes": "modes", "name": "name", "o": "origin", - "obj_id": "object_id", "off_dly": "off_delay", "on_cmd_type": "on_command_type", "ops": "options", @@ -186,6 +187,7 @@ "rgbww_cmd_t": "rgbww_command_topic", "rgbww_stat_t": "rgbww_state_topic", "rgbww_val_tpl": "rgbww_value_template", + "segmnts": "segments", "send_cmd_t": "send_command_topic", "send_if_off": "send_if_off", "set_fan_spd_t": "set_fan_speed_topic", diff --git a/homeassistant/components/mqtt/config.py b/homeassistant/components/mqtt/config.py index ed8f58218c6a94..1bf592032ad74b 100644 --- a/homeassistant/components/mqtt/config.py +++ b/homeassistant/components/mqtt/config.py @@ -10,6 +10,7 @@ from .const import ( CONF_COMMAND_TOPIC, CONF_ENCODING, + CONF_GROUP, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, @@ -23,6 +24,7 @@ SCHEMA_BASE = { vol.Optional(CONF_QOS, default=DEFAULT_QOS): valid_qos_schema, vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string, + vol.Optional(CONF_GROUP): vol.All(cv.ensure_list, [cv.string]), } MQTT_BASE_SCHEMA = vol.Schema(SCHEMA_BASE) diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 96300977722c7a..57d335685ebf97 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -71,7 +71,6 @@ CONF_BRIGHTNESS_STATE_TOPIC = "brightness_state_topic" CONF_BRIGHTNESS_TEMPLATE = "brightness_template" CONF_BRIGHTNESS_VALUE_TEMPLATE = "brightness_value_template" -CONF_COLOR_MODE = "color_mode" CONF_COLOR_MODE_STATE_TOPIC = "color_mode_state_topic" CONF_COLOR_MODE_VALUE_TEMPLATE = "color_mode_value_template" CONF_COLOR_TEMP_COMMAND_TEMPLATE = "color_temp_command_template" @@ -110,6 +109,7 @@ CONF_GET_POSITION_TEMPLATE = "position_template" CONF_GET_POSITION_TOPIC = "position_topic" CONF_GREEN_TEMPLATE = "green_template" +CONF_GROUP = "group" CONF_HS_COMMAND_TEMPLATE = "hs_command_template" CONF_HS_COMMAND_TOPIC = "hs_command_topic" CONF_HS_STATE_TOPIC = "hs_state_topic" @@ -269,7 +269,6 @@ CONF_DEPRECATED_VIA_HUB = "via_hub" CONF_SUGGESTED_AREA = "suggested_area" CONF_CONFIGURATION_URL = "configuration_url" -CONF_OBJECT_ID = "object_id" CONF_SUPPORT_URL = "support_url" DEFAULT_ALARM_CONTROL_PANEL_COMMAND_TEMPLATE = "{{action}}" diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index 141e0478f2f4bf..4bb23a9fa7e5fc 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -163,8 +163,6 @@ def _process_update_extra_state_attributes( latitude: float | None longitude: float | None gps_accuracy: float - # Reset manually set location to allow automatic zone detection - self._attr_location_name = None if isinstance( latitude := extra_state_attributes.get(ATTR_LATITUDE), (int, float) ) and isinstance( diff --git a/homeassistant/components/mqtt/diagnostics.py b/homeassistant/components/mqtt/diagnostics.py index 7a17c1f34093b5..4cd331ecaadf03 100644 --- a/homeassistant/components/mqtt/diagnostics.py +++ b/homeassistant/components/mqtt/diagnostics.py @@ -13,7 +13,7 @@ CONF_PASSWORD, CONF_USERNAME, ) -from homeassistant.core import HomeAssistant, callback, split_entity_id +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 @@ -103,10 +103,8 @@ def _state_dict(entity_entry: er.RegistryEntry) -> dict[str, Any] | None: # The context doesn't provide useful information in this case. state_dict.pop("context", None) - entity_domain = split_entity_id(state.entity_id)[0] - # Retract some sensitive state attributes - if entity_domain == device_tracker.DOMAIN: + if state.domain == device_tracker.DOMAIN: state_dict["attributes"] = async_redact_data( state_dict["attributes"], REDACT_STATE_DEVICE_TRACKER ) diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index 2b6f7237bfecdf..a101612f793218 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -29,7 +29,6 @@ CONF_MODEL_ID, CONF_NAME, CONF_UNIQUE_ID, - CONF_URL, CONF_VALUE_TEMPLATE, ) from homeassistant.core import Event, HassJobType, HomeAssistant, callback @@ -49,6 +48,7 @@ async_track_device_registry_updated_event, async_track_entity_registry_updated_event, ) +from homeassistant.helpers.group import IntegrationSpecificGroup from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ( @@ -79,13 +79,12 @@ CONF_ENABLED_BY_DEFAULT, CONF_ENCODING, CONF_ENTITY_PICTURE, + CONF_GROUP, CONF_HW_VERSION, CONF_IDENTIFIERS, CONF_JSON_ATTRS_TEMPLATE, CONF_JSON_ATTRS_TOPIC, CONF_MANUFACTURER, - CONF_OBJECT_ID, - CONF_ORIGIN, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, @@ -136,6 +135,7 @@ "device_class", "device_info", "entity_category", + "entity_id", "entity_picture", "entity_registry_enabled_default", "extra_state_attributes", @@ -463,7 +463,7 @@ def _async_setup_entities() -> None: class MqttAttributesMixin(Entity): - """Mixin used for platforms that support JSON attributes.""" + """Mixin used for platforms that support JSON attributes and group entities.""" _attributes_extra_blocked: frozenset[str] = frozenset() _attr_tpl: Callable[[ReceivePayloadType], ReceivePayloadType] | None = None @@ -471,10 +471,13 @@ class MqttAttributesMixin(Entity): [MessageCallbackType, set[str] | None, ReceiveMessage], None ] _process_update_extra_state_attributes: Callable[[dict[str, Any]], None] + group: IntegrationSpecificGroup | None def __init__(self, config: ConfigType) -> None: - """Initialize the JSON attributes mixin.""" + """Initialize the JSON attributes and handle group entities.""" self._attributes_sub_state: dict[str, EntitySubscription] = {} + if CONF_GROUP in config: + self.group = IntegrationSpecificGroup(self, config[CONF_GROUP]) self._attributes_config = config async def async_added_to_hass(self) -> None: @@ -485,6 +488,16 @@ async def async_added_to_hass(self) -> None: def attributes_prepare_discovery_update(self, config: DiscoveryInfoType) -> None: """Handle updated discovery message.""" + if CONF_GROUP in config: + if self.group is not None: + self.group.member_unique_ids = config[CONF_GROUP] + else: + _LOGGER.info( + "Group member update received for entity %s, " + "but this entity was not initialized with the `group` option. " + "Reload the MQTT integration or restart Home Assistant to activate" + ) + self._attributes_config = config self._attributes_prepare_subscribe_topics() @@ -546,7 +559,7 @@ def _attributes_message_received(self, msg: ReceiveMessage) -> None: _LOGGER.warning("Erroneous JSON: %s", payload) else: if isinstance(json_dict, dict): - filtered_dict = { + filtered_dict: dict[str, Any] = { k: v for k, v in json_dict.items() if k not in MQTT_ATTRIBUTES_BLOCKED @@ -1412,58 +1425,12 @@ def _init_entity_id(self) -> None: """Set entity_id from default_entity_id if defined in config.""" object_id: str default_entity_id: str | None - # Setting the default entity_id through the CONF_OBJECT_ID is deprecated - # Support will be removed with HA Core 2026.4 - if ( - CONF_DEFAULT_ENTITY_ID not in self._config - and CONF_OBJECT_ID not in self._config - ): - return if (default_entity_id := self._config.get(CONF_DEFAULT_ENTITY_ID)) is None: - object_id = self._config[CONF_OBJECT_ID] - else: - _, _, object_id = default_entity_id.partition(".") + return + _, _, object_id = default_entity_id.partition(".") self.entity_id = async_generate_entity_id( self._entity_id_format, object_id, None, self.hass ) - if CONF_OBJECT_ID in self._config: - domain = self.entity_id.split(".")[0] - if not self._discovery: - async_create_issue( - self.hass, - DOMAIN, - self.entity_id, - issue_domain=DOMAIN, - is_fixable=False, - breaks_in_ha_version="2026.4", - severity=IssueSeverity.WARNING, - learn_more_url=f"{learn_more_url(domain)}#default_enity_id", - translation_placeholders={ - "entity_id": self.entity_id, - "object_id": self._config[CONF_OBJECT_ID], - "domain": domain, - }, - translation_key="deprecated_object_id", - ) - elif CONF_DEFAULT_ENTITY_ID not in self._config: - if CONF_ORIGIN in self._config: - origin_name = self._config[CONF_ORIGIN][CONF_NAME] - url = self._config[CONF_ORIGIN].get(CONF_URL) - origin = f"[{origin_name}]({url})" if url else origin_name - else: - origin = "the integration" - _LOGGER.warning( - "The configuration for entity %s uses the deprecated option " - "`object_id` to set the default entity id. Replace the " - '`"object_id": "%s"` option with `"default_entity_id": ' - '"%s"` in your published discovery configuration to fix this ' - "issue, or contact the maintainer of %s that published this config " - "to fix this. This will stop working in Home Assistant Core 2026.4", - self.entity_id, - self._config[CONF_OBJECT_ID], - f"{domain}.{self._config[CONF_OBJECT_ID]}", - origin, - ) if self.unique_id is None: return @@ -1475,7 +1442,8 @@ def _init_entity_id(self) -> None: (entity_platform, DOMAIN, self.unique_id) ) ) and deleted_entry.entity_id != self.entity_id: - # Plan to update the entity_id basis on `object_id` if a deleted entity was found + # Plan to update the entity_id based on `default_entity_id` + # if a deleted entity was found self._update_registry_entity_id = self.entity_id @final @@ -1516,6 +1484,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) + self._process_entity_update() # Prepare MQTT subscriptions self.attributes_prepare_discovery_update(config) @@ -1618,6 +1587,10 @@ def _setup_common_attributes_from_config(self, config: ConfigType) -> None: def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" + @callback + def _process_entity_update(self) -> None: + """Process an entity discovery update.""" + @abstractmethod @callback def _prepare_subscribe_topics(self) -> None: diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index ed93701feca4d6..6b1db79e269fbc 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -35,13 +35,9 @@ ) from homeassistant.const import ( CONF_BRIGHTNESS, - CONF_COLOR_TEMP, CONF_EFFECT, - CONF_HS, CONF_NAME, CONF_OPTIMISTIC, - CONF_RGB, - CONF_XY, STATE_ON, ) from homeassistant.core import callback @@ -55,7 +51,6 @@ from .. import subscription from ..config import DEFAULT_QOS, DEFAULT_RETAIN, MQTT_RW_SCHEMA from ..const import ( - CONF_COLOR_MODE, CONF_COLOR_TEMP_KELVIN, CONF_COMMAND_TOPIC, CONF_EFFECT_LIST, @@ -96,7 +91,7 @@ DEFAULT_FLASH = True DEFAULT_TRANSITION = True -_PLATFORM_SCHEMA_BASE = ( +PLATFORM_SCHEMA_MODERN_JSON = ( MQTT_RW_SCHEMA.extend( { vol.Optional(CONF_BRIGHTNESS, default=DEFAULT_BRIGHTNESS): cv.boolean, @@ -139,24 +134,8 @@ .extend(MQTT_LIGHT_SCHEMA_SCHEMA.schema) ) -# Support for legacy color_mode handling was removed with HA Core 2025.3 -# The removed attributes can be removed from the schema's from HA Core 2026.3 DISCOVERY_SCHEMA_JSON = vol.All( - cv.removed(CONF_COLOR_MODE, raise_if_present=False), - cv.removed(CONF_COLOR_TEMP, raise_if_present=False), - cv.removed(CONF_HS, raise_if_present=False), - cv.removed(CONF_RGB, raise_if_present=False), - cv.removed(CONF_XY, raise_if_present=False), - _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), -) - -PLATFORM_SCHEMA_MODERN_JSON = vol.All( - cv.removed(CONF_COLOR_MODE), - cv.removed(CONF_COLOR_TEMP), - cv.removed(CONF_HS), - cv.removed(CONF_RGB), - cv.removed(CONF_XY), - _PLATFORM_SCHEMA_BASE, + PLATFORM_SCHEMA_MODERN_JSON.extend({}, extra=vol.REMOVE_EXTRA), ) diff --git a/homeassistant/components/mqtt/schemas.py b/homeassistant/components/mqtt/schemas.py index 0d577a76d809ea..9e7307d2bc4f42 100644 --- a/homeassistant/components/mqtt/schemas.py +++ b/homeassistant/components/mqtt/schemas.py @@ -42,7 +42,6 @@ CONF_JSON_ATTRS_TEMPLATE, CONF_JSON_ATTRS_TOPIC, CONF_MANUFACTURER, - CONF_OBJECT_ID, CONF_ORIGIN, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, @@ -173,7 +172,6 @@ def validate_device_has_at_least_one_identifier(value: ConfigType) -> ConfigType vol.Optional(CONF_JSON_ATTRS_TOPIC): valid_subscribe_topic, vol.Optional(CONF_JSON_ATTRS_TEMPLATE): cv.template, vol.Optional(CONF_DEFAULT_ENTITY_ID): cv.string, - vol.Optional(CONF_OBJECT_ID): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, } ) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index a0688576dc09ba..a50c39aa5ea258 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -1116,10 +1116,6 @@ } }, "issues": { - "deprecated_object_id": { - "description": "Entity {entity_id} uses the `object_id` option which is deprecated. To fix the issue, replace the `object_id: {object_id}` option with `default_entity_id: {domain}.{object_id}` in your \"configuration.yaml\", and restart Home Assistant.", - "title": "Deprecated option object_id used" - }, "invalid_platform_config": { "description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/config/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue.", "title": "Invalid config found for MQTT {domain} item" diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py index 6896d51ef93c6d..3ec8566029dfdb 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,7 +28,7 @@ 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 @@ -52,6 +53,9 @@ STATE_CLEANING: VacuumActivity.CLEANING, } +CONF_SEGMENTS = "segments" +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,39 @@ 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: + """Check for a valid configuration and check segments.""" + if (config[CONF_SEGMENTS] and CONF_CLEAN_SEGMENTS_COMMAND_TOPIC not in config) or ( + not config[CONF_SEGMENTS] and CONF_CLEAN_SEGMENTS_COMMAND_TOPIC in config + ): + raise vol.Invalid( + f"Options `{CONF_SEGMENTS}` and " + f"`{CONF_CLEAN_SEGMENTS_COMMAND_TOPIC}` must be defined together" + ) + segments: list[str] + if segments := config[CONF_SEGMENTS]: + if not config.get(CONF_UNIQUE_ID): + raise vol.Invalid( + f"Option `{CONF_SEGMENTS}` requires `{CONF_UNIQUE_ID}` to be configured" + ) + unique_segments: set[str] = set() + for segment in segments: + segment_id, _, _ = segment.partition(".") + if not segment_id or segment_id in unique_segments: + raise vol.Invalid( + f"The `{CONF_SEGMENTS}` option contains an invalid or non-" + f"unique segment ID '{segment_id}'. Got {segments}" + ) + unique_segments.add(segment_id) + + return config + + +_BASE_SCHEMA = MQTT_BASE_SCHEMA.extend( { + vol.Optional(CONF_SEGMENTS, default=[]): vol.All(cv.ensure_list, [cv.string]), + 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 +199,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 +229,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 _payloads: dict[str, str | None] def __init__( @@ -229,6 +269,23 @@ def _strings_to_services( self._attr_supported_features = _strings_to_services( supported_feature_strings, STRING_TO_SERVICE ) + if config[CONF_SEGMENTS] and CONF_CLEAN_SEGMENTS_COMMAND_TOPIC in config: + self._attr_supported_features |= VacuumEntityFeature.CLEAN_AREA + segments: list[str] = config[CONF_SEGMENTS] + self._segments = [ + Segment(id=segment_id, name=name or segment_id) + for segment_id, _, name in [ + segment.partition(".") for segment in segments + ] + ] + self._clean_segments_command_topic = config[ + 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) @@ -246,6 +303,20 @@ def _strings_to_services( ) } + @callback + def _process_entity_update(self) -> None: + """Check vacuum segments with registry entry.""" + if ( + self._attr_supported_features & VacuumEntityFeature.CLEAN_AREA + and (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 mqtt_async_added_to_hass(self) -> None: + """Check vacuum segments with registry entry.""" + self._process_entity_update() + def _update_state_attributes(self, payload: dict[str, Any]) -> None: """Update the entity state attributes.""" self._state_attrs.update(payload) @@ -277,6 +348,19 @@ 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.""" + await self.async_publish_with_config( + self._clean_segments_command_topic, + self._clean_segments_command_template( + json_dumps(segment_ids), {"value": segment_ids} + ), + ) + + async def async_get_segments(self) -> list[Segment]: + """Return the available segments.""" + return self._segments + async def _async_publish_command(self, feature: VacuumEntityFeature) -> None: """Publish a command.""" if self._command_topic is None: diff --git a/homeassistant/components/mqtt_room/sensor.py b/homeassistant/components/mqtt_room/sensor.py index 242c39cb98369c..10051bdeb16c02 100644 --- a/homeassistant/components/mqtt_room/sensor.py +++ b/homeassistant/components/mqtt_room/sensor.py @@ -173,7 +173,7 @@ def name(self): return self._name @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return {ATTR_DISTANCE: self._distance} diff --git a/homeassistant/components/mta/__init__.py b/homeassistant/components/mta/__init__.py new file mode 100644 index 00000000000000..231b6e768c6c54 --- /dev/null +++ b/homeassistant/components/mta/__init__.py @@ -0,0 +1,50 @@ +"""The MTA New York City Transit integration.""" + +from __future__ import annotations + +import asyncio + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN as DOMAIN, SUBENTRY_TYPE_BUS, SUBENTRY_TYPE_SUBWAY +from .coordinator import MTAConfigEntry, MTADataUpdateCoordinator + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: MTAConfigEntry) -> bool: + """Set up MTA from a config entry.""" + coordinators: dict[str, MTADataUpdateCoordinator] = {} + + for subentry_id, subentry in entry.subentries.items(): + if subentry.subentry_type not in (SUBENTRY_TYPE_SUBWAY, SUBENTRY_TYPE_BUS): + continue + + coordinators[subentry_id] = MTADataUpdateCoordinator(hass, entry, subentry) + + # Refresh all coordinators in parallel + await asyncio.gather( + *( + coordinator.async_config_entry_first_refresh() + for coordinator in coordinators.values() + ) + ) + + entry.runtime_data = coordinators + + entry.async_on_unload(entry.add_update_listener(async_update_entry)) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_update_entry(hass: HomeAssistant, entry: MTAConfigEntry) -> None: + """Handle config entry update (e.g., subentry changes).""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: MTAConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/mta/config_flow.py b/homeassistant/components/mta/config_flow.py new file mode 100644 index 00000000000000..e3b1f315eeccdf --- /dev/null +++ b/homeassistant/components/mta/config_flow.py @@ -0,0 +1,368 @@ +"""Config flow for MTA New York City Transit integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from pymta import LINE_TO_FEED, BusFeed, MTAFeedError, SubwayFeed +import voluptuous as vol + +from homeassistant.config_entries import ( + SOURCE_REAUTH, + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import CONF_API_KEY +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import ( + CONF_LINE, + CONF_ROUTE, + CONF_STOP_ID, + CONF_STOP_NAME, + DOMAIN, + SUBENTRY_TYPE_BUS, + SUBENTRY_TYPE_SUBWAY, +) + +_LOGGER = logging.getLogger(__name__) + + +class MTAConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for MTA.""" + + VERSION = 1 + MINOR_VERSION = 1 + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this handler.""" + return { + SUBENTRY_TYPE_SUBWAY: SubwaySubentryFlowHandler, + SUBENTRY_TYPE_BUS: BusSubentryFlowHandler, + } + + 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: + api_key = user_input.get(CONF_API_KEY) + self._async_abort_entries_match({CONF_API_KEY: api_key}) + if api_key: + # Test the API key by trying to fetch bus data + session = async_get_clientsession(self.hass) + bus_feed = BusFeed(api_key=api_key, session=session) + try: + # Try to get stops for a known route to validate the key + await bus_feed.get_stops(route_id="M15") + except MTAFeedError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error validating API key") + errors["base"] = "unknown" + if not errors: + if self.source == SOURCE_REAUTH: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={CONF_API_KEY: api_key or None}, + ) + return self.async_create_entry( + title="MTA", + data={CONF_API_KEY: api_key or None}, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Optional(CONF_API_KEY): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } + ), + errors=errors, + ) + + async def async_step_reauth( + self, _entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauth when user wants to add or update API key.""" + return await self.async_step_user() + + +class SubwaySubentryFlowHandler(ConfigSubentryFlow): + """Handle subway stop subentry flow.""" + + def __init__(self) -> None: + """Initialize the subentry flow.""" + self.data: dict[str, Any] = {} + self.stops: dict[str, str] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle the line selection step.""" + if user_input is not None: + self.data[CONF_LINE] = user_input[CONF_LINE] + return await self.async_step_stop() + + lines = sorted(LINE_TO_FEED.keys()) + line_options = [SelectOptionDict(value=line, label=line) for line in lines] + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_LINE): SelectSelector( + SelectSelectorConfig( + options=line_options, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + ) + + async def async_step_stop( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle the stop selection step.""" + errors: dict[str, str] = {} + + if user_input is not None: + stop_id = user_input[CONF_STOP_ID] + self.data[CONF_STOP_ID] = stop_id + stop_name = self.stops.get(stop_id, stop_id) + self.data[CONF_STOP_NAME] = stop_name + + unique_id = f"{self.data[CONF_LINE]}_{stop_id}" + + # Check for duplicate subentries across all entries + for entry in self.hass.config_entries.async_entries(DOMAIN): + for subentry in entry.subentries.values(): + if subentry.unique_id == unique_id: + return self.async_abort(reason="already_configured") + + # Test connection to real-time GTFS-RT feed + try: + await self._async_test_connection() + except MTAFeedError: + errors["base"] = "cannot_connect" + else: + title = f"{self.data[CONF_LINE]} - {stop_name}" + return self.async_create_entry( + title=title, + data=self.data, + unique_id=unique_id, + ) + + try: + self.stops = await self._async_get_stops(self.data[CONF_LINE]) + except MTAFeedError: + _LOGGER.debug("Error fetching stops for line %s", self.data[CONF_LINE]) + return self.async_abort(reason="cannot_connect") + + if not self.stops: + _LOGGER.error("No stops found for line %s", self.data[CONF_LINE]) + return self.async_abort(reason="no_stops") + + stop_options = [ + SelectOptionDict(value=stop_id, label=stop_name) + for stop_id, stop_name in sorted(self.stops.items(), key=lambda x: x[1]) + ] + + return self.async_show_form( + step_id="stop", + data_schema=vol.Schema( + { + vol.Required(CONF_STOP_ID): SelectSelector( + SelectSelectorConfig( + options=stop_options, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + errors=errors, + description_placeholders={"line": self.data[CONF_LINE]}, + ) + + async def _async_get_stops(self, line: str) -> dict[str, str]: + """Get stops for a line from the library.""" + feed_id = SubwayFeed.get_feed_id_for_route(line) + session = async_get_clientsession(self.hass) + + subway_feed = SubwayFeed(feed_id=feed_id, session=session) + stops_list = await subway_feed.get_stops(route_id=line) + + stops = {} + for stop in stops_list: + stop_id = stop["stop_id"] + stop_name = stop["stop_name"] + # Add direction label (stop_id always ends in N or S) + direction = stop_id[-1] + stops[stop_id] = f"{stop_name} ({direction} direction)" + + return stops + + async def _async_test_connection(self) -> None: + """Test connection to MTA feed.""" + feed_id = SubwayFeed.get_feed_id_for_route(self.data[CONF_LINE]) + session = async_get_clientsession(self.hass) + + subway_feed = SubwayFeed(feed_id=feed_id, session=session) + await subway_feed.get_arrivals( + route_id=self.data[CONF_LINE], + stop_id=self.data[CONF_STOP_ID], + max_arrivals=1, + ) + + +class BusSubentryFlowHandler(ConfigSubentryFlow): + """Handle bus stop subentry flow.""" + + def __init__(self) -> None: + """Initialize the subentry flow.""" + self.data: dict[str, Any] = {} + self.stops: dict[str, str] = {} + + def _get_api_key(self) -> str: + """Get API key from parent entry.""" + return self._get_entry().data.get(CONF_API_KEY) or "" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle the route input step.""" + errors: dict[str, str] = {} + + if user_input is not None: + route = user_input[CONF_ROUTE].upper().strip() + self.data[CONF_ROUTE] = route + + # Validate route by fetching stops + try: + self.stops = await self._async_get_stops(route) + if not self.stops: + errors["base"] = "invalid_route" + else: + return await self.async_step_stop() + except MTAFeedError: + _LOGGER.debug("Error fetching stops for route %s", route) + errors["base"] = "invalid_route" + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_ROUTE): TextSelector(), + } + ), + errors=errors, + ) + + async def async_step_stop( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle the stop selection step.""" + errors: dict[str, str] = {} + + if user_input is not None: + stop_id = user_input[CONF_STOP_ID] + self.data[CONF_STOP_ID] = stop_id + stop_name = self.stops.get(stop_id, stop_id) + self.data[CONF_STOP_NAME] = stop_name + + unique_id = f"bus_{self.data[CONF_ROUTE]}_{stop_id}" + + # Check for duplicate subentries across all entries + for entry in self.hass.config_entries.async_entries(DOMAIN): + for subentry in entry.subentries.values(): + if subentry.unique_id == unique_id: + return self.async_abort(reason="already_configured") + + # Test connection to real-time feed + try: + await self._async_test_connection() + except MTAFeedError: + errors["base"] = "cannot_connect" + else: + title = f"{self.data[CONF_ROUTE]} - {stop_name}" + return self.async_create_entry( + title=title, + data=self.data, + unique_id=unique_id, + ) + + stop_options = [ + SelectOptionDict(value=stop_id, label=stop_name) + for stop_id, stop_name in sorted(self.stops.items(), key=lambda x: x[1]) + ] + + return self.async_show_form( + step_id="stop", + data_schema=vol.Schema( + { + vol.Required(CONF_STOP_ID): SelectSelector( + SelectSelectorConfig( + options=stop_options, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + errors=errors, + description_placeholders={"route": self.data[CONF_ROUTE]}, + ) + + async def _async_get_stops(self, route: str) -> dict[str, str]: + """Get stops for a bus route from the library.""" + session = async_get_clientsession(self.hass) + api_key = self._get_api_key() + + bus_feed = BusFeed(api_key=api_key, session=session) + stops_list = await bus_feed.get_stops(route_id=route) + + stops = {} + for stop in stops_list: + stop_id = stop["stop_id"] + stop_name = stop["stop_name"] + # Add direction if available (e.g., "to South Ferry") + if direction := stop.get("direction_name"): + stops[stop_id] = f"{stop_name} (to {direction})" + else: + stops[stop_id] = stop_name + + return stops + + async def _async_test_connection(self) -> None: + """Test connection to MTA bus feed.""" + session = async_get_clientsession(self.hass) + api_key = self._get_api_key() + + bus_feed = BusFeed(api_key=api_key, session=session) + await bus_feed.get_arrivals( + route_id=self.data[CONF_ROUTE], + stop_id=self.data[CONF_STOP_ID], + max_arrivals=1, + ) diff --git a/homeassistant/components/mta/const.py b/homeassistant/components/mta/const.py new file mode 100644 index 00000000000000..30e70eaebcf2c7 --- /dev/null +++ b/homeassistant/components/mta/const.py @@ -0,0 +1,15 @@ +"""Constants for the MTA New York City Transit integration.""" + +from datetime import timedelta + +DOMAIN = "mta" + +CONF_LINE = "line" +CONF_STOP_ID = "stop_id" +CONF_STOP_NAME = "stop_name" +CONF_ROUTE = "route" + +SUBENTRY_TYPE_SUBWAY = "subway" +SUBENTRY_TYPE_BUS = "bus" + +UPDATE_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/mta/coordinator.py b/homeassistant/components/mta/coordinator.py new file mode 100644 index 00000000000000..775e9f1e411b01 --- /dev/null +++ b/homeassistant/components/mta/coordinator.py @@ -0,0 +1,131 @@ +"""Data update coordinator for MTA New York City Transit.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +import logging + +from pymta import BusFeed, MTAFeedError, SubwayFeed + +from homeassistant.config_entries import ConfigEntry, ConfigSubentry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +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_LINE, + CONF_ROUTE, + CONF_STOP_ID, + DOMAIN, + SUBENTRY_TYPE_BUS, + UPDATE_INTERVAL, +) + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class MTAArrival: + """Represents a single transit arrival.""" + + arrival_time: datetime + minutes_until: int + route_id: str + destination: str + + +@dataclass +class MTAData: + """Data for MTA arrivals.""" + + arrivals: list[MTAArrival] + + +type MTAConfigEntry = ConfigEntry[dict[str, MTADataUpdateCoordinator]] + + +class MTADataUpdateCoordinator(DataUpdateCoordinator[MTAData]): + """Class to manage fetching MTA data.""" + + config_entry: MTAConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: MTAConfigEntry, + subentry: ConfigSubentry, + ) -> None: + """Initialize.""" + self.subentry = subentry + self.stop_id = subentry.data[CONF_STOP_ID] + + session = async_get_clientsession(hass) + + if subentry.subentry_type == SUBENTRY_TYPE_BUS: + api_key = config_entry.data.get(CONF_API_KEY) or "" + self.feed: BusFeed | SubwayFeed = BusFeed(api_key=api_key, session=session) + self.route_id = subentry.data[CONF_ROUTE] + else: + # Subway feed + line = subentry.data[CONF_LINE] + feed_id = SubwayFeed.get_feed_id_for_route(line) + self.feed = SubwayFeed(feed_id=feed_id, session=session) + self.route_id = line + + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=f"{DOMAIN}_{subentry.subentry_id}", + update_interval=UPDATE_INTERVAL, + ) + + async def _async_update_data(self) -> MTAData: + """Fetch data from MTA.""" + _LOGGER.debug( + "Fetching data for route=%s, stop=%s", + self.route_id, + self.stop_id, + ) + + try: + library_arrivals = await self.feed.get_arrivals( + route_id=self.route_id, + stop_id=self.stop_id, + max_arrivals=3, + ) + except MTAFeedError as err: + raise UpdateFailed(f"Error fetching MTA data: {err}") from err + + now = dt_util.now() + arrivals: list[MTAArrival] = [] + + for library_arrival in library_arrivals: + # Convert UTC arrival time to local time + arrival_time = dt_util.as_local(library_arrival.arrival_time) + + minutes_until = int((arrival_time - now).total_seconds() / 60) + + _LOGGER.debug( + "Stop %s: arrival_time=%s, minutes_until=%d, route=%s", + library_arrival.stop_id, + arrival_time, + minutes_until, + library_arrival.route_id, + ) + + arrivals.append( + MTAArrival( + arrival_time=arrival_time, + minutes_until=minutes_until, + route_id=library_arrival.route_id, + destination=library_arrival.destination, + ) + ) + + _LOGGER.debug("Returning %d arrivals", len(arrivals)) + + return MTAData(arrivals=arrivals) diff --git a/homeassistant/components/mta/manifest.json b/homeassistant/components/mta/manifest.json new file mode 100644 index 00000000000000..a9a5eedfbcfe3b --- /dev/null +++ b/homeassistant/components/mta/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "mta", + "name": "MTA New York City Transit", + "codeowners": ["@OnFreund"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/mta", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["pymta"], + "quality_scale": "silver", + "requirements": ["py-nymta==0.4.0"] +} diff --git a/homeassistant/components/mta/quality_scale.yaml b/homeassistant/components/mta/quality_scale.yaml new file mode 100644 index 00000000000000..10752cbef79bfa --- /dev/null +++ b/homeassistant/components/mta/quality_scale.yaml @@ -0,0 +1,86 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not register custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Integration does not explicitly subscribe to events in async_added_to_hass. + 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 register custom actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No configuration options. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: No discovery. + discovery: + status: exempt + comment: No discovery. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: + status: exempt + comment: No physical devices. + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: Integration tracks a single configured stop, not dynamically discovered devices. + entity-category: + status: exempt + comment: All entities are primary entities without specific categories. + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: N/A + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No repairs needed currently. + stale-devices: + status: exempt + comment: Integration tracks a single configured stop per entry, devices cannot become stale. + + # Platinum + async-dependency: todo + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/mta/sensor.py b/homeassistant/components/mta/sensor.py new file mode 100644 index 00000000000000..a6dbee6461166c --- /dev/null +++ b/homeassistant/components/mta/sensor.py @@ -0,0 +1,159 @@ +"""Sensor platform for MTA New York City Transit.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigSubentry +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_LINE, CONF_ROUTE, CONF_STOP_NAME, DOMAIN, SUBENTRY_TYPE_BUS +from .coordinator import MTAArrival, MTAConfigEntry, MTADataUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class MTASensorEntityDescription(SensorEntityDescription): + """Describes an MTA sensor entity.""" + + arrival_index: int + value_fn: Callable[[MTAArrival], datetime | str] + + +SENSOR_DESCRIPTIONS: tuple[MTASensorEntityDescription, ...] = ( + MTASensorEntityDescription( + key="next_arrival", + translation_key="next_arrival", + device_class=SensorDeviceClass.TIMESTAMP, + arrival_index=0, + value_fn=lambda arrival: arrival.arrival_time, + ), + MTASensorEntityDescription( + key="next_arrival_route", + translation_key="next_arrival_route", + arrival_index=0, + value_fn=lambda arrival: arrival.route_id, + ), + MTASensorEntityDescription( + key="next_arrival_destination", + translation_key="next_arrival_destination", + arrival_index=0, + value_fn=lambda arrival: arrival.destination, + ), + MTASensorEntityDescription( + key="second_arrival", + translation_key="second_arrival", + device_class=SensorDeviceClass.TIMESTAMP, + arrival_index=1, + value_fn=lambda arrival: arrival.arrival_time, + ), + MTASensorEntityDescription( + key="second_arrival_route", + translation_key="second_arrival_route", + arrival_index=1, + value_fn=lambda arrival: arrival.route_id, + ), + MTASensorEntityDescription( + key="second_arrival_destination", + translation_key="second_arrival_destination", + arrival_index=1, + value_fn=lambda arrival: arrival.destination, + ), + MTASensorEntityDescription( + key="third_arrival", + translation_key="third_arrival", + device_class=SensorDeviceClass.TIMESTAMP, + arrival_index=2, + value_fn=lambda arrival: arrival.arrival_time, + ), + MTASensorEntityDescription( + key="third_arrival_route", + translation_key="third_arrival_route", + arrival_index=2, + value_fn=lambda arrival: arrival.route_id, + ), + MTASensorEntityDescription( + key="third_arrival_destination", + translation_key="third_arrival_destination", + arrival_index=2, + value_fn=lambda arrival: arrival.destination, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MTAConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up MTA sensor based on a config entry.""" + for subentry_id, coordinator in entry.runtime_data.items(): + subentry = entry.subentries[subentry_id] + async_add_entities( + ( + MTASensor(coordinator, subentry, description) + for description in SENSOR_DESCRIPTIONS + ), + config_subentry_id=subentry_id, + ) + + +class MTASensor(CoordinatorEntity[MTADataUpdateCoordinator], SensorEntity): + """Sensor for MTA transit arrivals.""" + + _attr_has_entity_name = True + entity_description: MTASensorEntityDescription + + def __init__( + self, + coordinator: MTADataUpdateCoordinator, + subentry: ConfigSubentry, + description: MTASensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + + self.entity_description = description + + is_bus = subentry.subentry_type == SUBENTRY_TYPE_BUS + if is_bus: + route = subentry.data[CONF_ROUTE] + model = "Bus" + else: + route = subentry.data[CONF_LINE] + model = "Subway" + + stop_name = subentry.data.get(CONF_STOP_NAME, subentry.subentry_id) + + unique_id = subentry.unique_id or subentry.subentry_id + self._attr_unique_id = f"{unique_id}-{description.key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=f"{route} - {stop_name}", + manufacturer="MTA", + model=model, + entry_type=DeviceEntryType.SERVICE, + ) + + @property + def native_value(self) -> datetime | str | None: + """Return the state of the sensor.""" + arrivals = self.coordinator.data.arrivals + if len(arrivals) <= self.entity_description.arrival_index: + return None + + return self.entity_description.value_fn( + arrivals[self.entity_description.arrival_index] + ) diff --git a/homeassistant/components/mta/strings.json b/homeassistant/components/mta/strings.json new file mode 100644 index 00000000000000..ebccf2a1f9ed3e --- /dev/null +++ b/homeassistant/components/mta/strings.json @@ -0,0 +1,128 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "API key from MTA Bus Time. Required for bus tracking, optional for subway only." + }, + "description": "Enter your MTA Bus Time API key to enable bus tracking. Leave blank if you only want to track subways." + } + } + }, + "config_subentries": { + "bus": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "entry_type": "Bus stop", + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_route": "Invalid bus route. Please check the route name and try again." + }, + "initiate_flow": { + "user": "Add bus stop" + }, + "step": { + "stop": { + "data": { + "stop_id": "Stop" + }, + "data_description": { + "stop_id": "Select the stop you want to track" + }, + "description": "Choose a stop on the {route} route.", + "title": "Select stop" + }, + "user": { + "data": { + "route": "Route" + }, + "data_description": { + "route": "The bus route identifier" + }, + "description": "Enter the bus route you want to track (for example, M15, B46, Q10).", + "title": "Enter bus route" + } + } + }, + "subway": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "no_stops": "No stops found for this line. The line may not be currently running." + }, + "entry_type": "Subway stop", + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "initiate_flow": { + "user": "Add subway stop" + }, + "step": { + "stop": { + "data": { + "stop_id": "Stop and direction" + }, + "data_description": { + "stop_id": "Select the stop and direction you want to track" + }, + "description": "Choose a stop on the {line} line. The direction is included with each stop.", + "title": "Select stop and direction" + }, + "user": { + "data": { + "line": "Line" + }, + "data_description": { + "line": "The subway line to track" + }, + "description": "Choose the subway line you want to track.", + "title": "Select subway line" + } + } + } + }, + "entity": { + "sensor": { + "next_arrival": { + "name": "Next arrival" + }, + "next_arrival_destination": { + "name": "Next arrival destination" + }, + "next_arrival_route": { + "name": "Next arrival route" + }, + "second_arrival": { + "name": "Second arrival" + }, + "second_arrival_destination": { + "name": "Second arrival destination" + }, + "second_arrival_route": { + "name": "Second arrival route" + }, + "third_arrival": { + "name": "Third arrival" + }, + "third_arrival_destination": { + "name": "Third arrival destination" + }, + "third_arrival_route": { + "name": "Third arrival route" + } + } + } +} diff --git a/homeassistant/components/mullvad/__init__.py b/homeassistant/components/mullvad/__init__.py index f2f6f39c96f150..dad0506ff82c8e 100644 --- a/homeassistant/components/mullvad/__init__.py +++ b/homeassistant/components/mullvad/__init__.py @@ -1,37 +1,18 @@ """The Mullvad VPN integration.""" -import asyncio -from datetime import timedelta -import logging - -from mullvad_api import MullvadAPI - from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN +from .coordinator import MullvadCoordinator PLATFORMS = [Platform.BINARY_SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Mullvad VPN integration.""" - - async def async_get_mullvad_api_data(): - async with asyncio.timeout(10): - api = await hass.async_add_executor_job(MullvadAPI) - return api.data - - coordinator = DataUpdateCoordinator( - hass, - logging.getLogger(__name__), - config_entry=entry, - name=DOMAIN, - update_method=async_get_mullvad_api_data, - update_interval=timedelta(minutes=1), - ) + coordinator = MullvadCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() hass.data[DOMAIN] = coordinator diff --git a/homeassistant/components/mullvad/binary_sensor.py b/homeassistant/components/mullvad/binary_sensor.py index ad488058025b4c..3984b2fec08026 100644 --- a/homeassistant/components/mullvad/binary_sensor.py +++ b/homeassistant/components/mullvad/binary_sensor.py @@ -9,12 +9,10 @@ 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, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import MullvadCoordinator BINARY_SENSORS = ( BinarySensorEntityDescription( @@ -39,14 +37,14 @@ async def async_setup_entry( ) -class MullvadBinarySensor(CoordinatorEntity, BinarySensorEntity): +class MullvadBinarySensor(CoordinatorEntity[MullvadCoordinator], BinarySensorEntity): """Represents a Mullvad binary sensor.""" _attr_has_entity_name = True def __init__( self, - coordinator: DataUpdateCoordinator, + coordinator: MullvadCoordinator, entity_description: BinarySensorEntityDescription, config_entry: ConfigEntry, ) -> None: diff --git a/homeassistant/components/mullvad/coordinator.py b/homeassistant/components/mullvad/coordinator.py new file mode 100644 index 00000000000000..7d613d719cade1 --- /dev/null +++ b/homeassistant/components/mullvad/coordinator.py @@ -0,0 +1,38 @@ +"""The Mullvad VPN coordinator.""" + +import asyncio +from datetime import timedelta +import logging +from typing import Any + +from mullvad_api import MullvadAPI + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class MullvadCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Mullvad VPN data update coordinator.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the Mullvad coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=timedelta(minutes=1), + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from Mullvad API.""" + async with asyncio.timeout(10): + api = await self.hass.async_add_executor_job(MullvadAPI) + return api.data diff --git a/homeassistant/components/mutesync/__init__.py b/homeassistant/components/mutesync/__init__.py index d5d2e3414d566b..8c1347b2b04e65 100644 --- a/homeassistant/components/mutesync/__init__.py +++ b/homeassistant/components/mutesync/__init__.py @@ -2,54 +2,20 @@ from __future__ import annotations -import asyncio -import logging - -import mutesync - from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import update_coordinator -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, UPDATE_INTERVAL_IN_MEETING, UPDATE_INTERVAL_NOT_IN_MEETING +from .const import DOMAIN +from .coordinator import MutesyncUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up mütesync from a config entry.""" - client = mutesync.PyMutesync( - entry.data["token"], - entry.data["host"], - async_get_clientsession(hass), - ) - - async def update_data(): - """Update the data.""" - async with asyncio.timeout(2.5): - state = await client.get_state() - - if state["muted"] is None or state["in_meeting"] is None: - raise update_coordinator.UpdateFailed("Got invalid response") - - if state["in_meeting"]: - coordinator.update_interval = UPDATE_INTERVAL_IN_MEETING - else: - coordinator.update_interval = UPDATE_INTERVAL_NOT_IN_MEETING - - return state - coordinator = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ( - update_coordinator.DataUpdateCoordinator( - hass, - logging.getLogger(__name__), - config_entry=entry, - name=DOMAIN, - update_interval=UPDATE_INTERVAL_NOT_IN_MEETING, - update_method=update_data, - ) + MutesyncUpdateCoordinator(hass, entry) ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/mutesync/binary_sensor.py b/homeassistant/components/mutesync/binary_sensor.py index 7a9025762ef78a..66fe78e931cb93 100644 --- a/homeassistant/components/mutesync/binary_sensor.py +++ b/homeassistant/components/mutesync/binary_sensor.py @@ -3,11 +3,12 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers import update_coordinator 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 SENSORS = ( "in_meeting", @@ -27,7 +28,7 @@ async def async_setup_entry( ) -class MuteStatus(update_coordinator.CoordinatorEntity, BinarySensorEntity): +class MuteStatus(CoordinatorEntity[MutesyncUpdateCoordinator], BinarySensorEntity): """Mütesync binary sensors.""" _attr_has_entity_name = True @@ -48,6 +49,6 @@ def __init__(self, coordinator, sensor_type): ) @property - def is_on(self): + def is_on(self) -> bool: """Return the state of the sensor.""" return self.coordinator.data[self._sensor_type] diff --git a/homeassistant/components/mutesync/coordinator.py b/homeassistant/components/mutesync/coordinator.py new file mode 100644 index 00000000000000..03c545c7e24b17 --- /dev/null +++ b/homeassistant/components/mutesync/coordinator.py @@ -0,0 +1,58 @@ +"""Coordinator for the mütesync integration.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +import mutesync + +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, UpdateFailed + +from .const import DOMAIN, UPDATE_INTERVAL_IN_MEETING, UPDATE_INTERVAL_NOT_IN_MEETING + +_LOGGER = logging.getLogger(__name__) + + +class MutesyncUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Coordinator for the mütesync integration.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + config_entry=entry, + update_interval=UPDATE_INTERVAL_NOT_IN_MEETING, + ) + self._client = mutesync.PyMutesync( + entry.data["token"], + entry.data["host"], + async_get_clientsession(hass), + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Get data from the mütesync client.""" + async with asyncio.timeout(2.5): + state = await self._client.get_state() + + if state["muted"] is None or state["in_meeting"] is None: + raise UpdateFailed("Got invalid response") + + if state["in_meeting"]: + self.update_interval = UPDATE_INTERVAL_IN_MEETING + else: + self.update_interval = UPDATE_INTERVAL_NOT_IN_MEETING + + return state diff --git a/homeassistant/components/myneomitis/__init__.py b/homeassistant/components/myneomitis/__init__.py new file mode 100644 index 00000000000000..ab27ae01585385 --- /dev/null +++ b/homeassistant/components/myneomitis/__init__.py @@ -0,0 +1,130 @@ +"""Integration for MyNeomitis.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging +from typing import Any + +import aiohttp +import pyaxencoapi + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_EMAIL, + CONF_PASSWORD, + EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.SELECT] + + +@dataclass +class MyNeomitisRuntimeData: + """Runtime data for MyNeomitis integration.""" + + api: pyaxencoapi.PyAxencoAPI + devices: list[dict[str, Any]] + + +type MyNeomitisConfigEntry = ConfigEntry[MyNeomitisRuntimeData] + + +async def async_setup_entry(hass: HomeAssistant, entry: MyNeomitisConfigEntry) -> bool: + """Set up MyNeomitis from a config entry.""" + session = async_get_clientsession(hass) + + email: str = entry.data[CONF_EMAIL] + password: str = entry.data[CONF_PASSWORD] + + api = pyaxencoapi.PyAxencoAPI(session) + connected = False + try: + await api.login(email, password) + await api.connect_websocket() + connected = True + _LOGGER.debug("Successfully connected to Login/WebSocket") + + # Retrieve the user's devices + devices: list[dict[str, Any]] = await api.get_devices() + + except aiohttp.ClientResponseError as err: + if connected: + try: + await api.disconnect_websocket() + except ( + TimeoutError, + ConnectionError, + aiohttp.ClientError, + ) as disconnect_err: + _LOGGER.error( + "Error while disconnecting WebSocket for %s: %s", + entry.entry_id, + disconnect_err, + ) + if err.status == 401: + raise ConfigEntryAuthFailed( + "Authentication failed, please update your credentials" + ) from err + raise ConfigEntryNotReady(f"Error connecting to API: {err}") from err + except (TimeoutError, ConnectionError, aiohttp.ClientError) as err: + if connected: + try: + await api.disconnect_websocket() + except ( + TimeoutError, + ConnectionError, + aiohttp.ClientError, + ) as disconnect_err: + _LOGGER.error( + "Error while disconnecting WebSocket for %s: %s", + entry.entry_id, + disconnect_err, + ) + raise ConfigEntryNotReady(f"Error connecting to API/WebSocket: {err}") from err + + entry.runtime_data = MyNeomitisRuntimeData(api=api, devices=devices) + + async def _async_disconnect_websocket(_event: Event) -> None: + """Disconnect WebSocket on Home Assistant shutdown.""" + try: + await api.disconnect_websocket() + except (TimeoutError, ConnectionError, aiohttp.ClientError) as err: + _LOGGER.error( + "Error while disconnecting WebSocket for %s: %s", + entry.entry_id, + err, + ) + + entry.async_on_unload( + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, _async_disconnect_websocket + ) + ) + + # Load platforms + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +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) + if unload_ok: + try: + await entry.runtime_data.api.disconnect_websocket() + except (TimeoutError, ConnectionError) as err: + _LOGGER.error( + "Error while disconnecting WebSocket for %s: %s", + entry.entry_id, + err, + ) + + return unload_ok diff --git a/homeassistant/components/myneomitis/config_flow.py b/homeassistant/components/myneomitis/config_flow.py new file mode 100644 index 00000000000000..df6b9696e7ebef --- /dev/null +++ b/homeassistant/components/myneomitis/config_flow.py @@ -0,0 +1,78 @@ +"""Config flow for MyNeomitis integration.""" + +import logging +from typing import Any + +import aiohttp +from pyaxencoapi import PyAxencoAPI +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_USER_ID, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class MyNeoConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle the configuration flow for the MyNeomitis integration.""" + + VERSION = 1 + MINOR_VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step of the configuration flow.""" + errors: dict[str, str] = {} + + if user_input is not None: + email: str = user_input[CONF_EMAIL] + password: str = user_input[CONF_PASSWORD] + + session = async_get_clientsession(self.hass) + api = PyAxencoAPI(session) + + try: + await api.login(email, password) + except aiohttp.ClientResponseError as e: + if e.status == 401: + errors["base"] = "invalid_auth" + elif e.status >= 500: + errors["base"] = "cannot_connect" + else: + errors["base"] = "unknown" + except aiohttp.ClientConnectionError: + errors["base"] = "cannot_connect" + except aiohttp.ClientError: + errors["base"] = "unknown" + except Exception: + _LOGGER.exception("Unexpected error during login") + errors["base"] = "unknown" + + if not errors: + # Prevent duplicate configuration with the same user ID + await self.async_set_unique_id(api.user_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=f"MyNeomitis ({email})", + data={ + CONF_EMAIL: email, + CONF_PASSWORD: password, + CONF_USER_ID: api.user_id, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/myneomitis/const.py b/homeassistant/components/myneomitis/const.py new file mode 100644 index 00000000000000..c5f5e6b9ffe467 --- /dev/null +++ b/homeassistant/components/myneomitis/const.py @@ -0,0 +1,4 @@ +"""Constants for the MyNeomitis integration.""" + +DOMAIN = "myneomitis" +CONF_USER_ID = "user_id" diff --git a/homeassistant/components/myneomitis/icons.json b/homeassistant/components/myneomitis/icons.json new file mode 100644 index 00000000000000..8814be2396dafd --- /dev/null +++ b/homeassistant/components/myneomitis/icons.json @@ -0,0 +1,31 @@ +{ + "entity": { + "select": { + "pilote": { + "state": { + "antifrost": "mdi:snowflake", + "auto": "mdi:refresh-auto", + "boost": "mdi:rocket-launch", + "comfort": "mdi:fire", + "eco": "mdi:leaf", + "eco_1": "mdi:leaf", + "eco_2": "mdi:leaf", + "standby": "mdi:toggle-switch-off-outline" + } + }, + "relais": { + "state": { + "auto": "mdi:refresh-auto", + "off": "mdi:toggle-switch-off-outline", + "on": "mdi:toggle-switch" + } + }, + "ufh": { + "state": { + "cooling": "mdi:snowflake", + "heating": "mdi:fire" + } + } + } + } +} diff --git a/homeassistant/components/myneomitis/manifest.json b/homeassistant/components/myneomitis/manifest.json new file mode 100644 index 00000000000000..b9dfa39dd83533 --- /dev/null +++ b/homeassistant/components/myneomitis/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "myneomitis", + "name": "MyNeomitis", + "codeowners": ["@l-pr"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/myneomitis", + "integration_type": "hub", + "iot_class": "cloud_push", + "quality_scale": "bronze", + "requirements": ["pyaxencoapi==1.0.6"] +} diff --git a/homeassistant/components/myneomitis/quality_scale.yaml b/homeassistant/components/myneomitis/quality_scale.yaml new file mode 100644 index 00000000000000..b1526815b71452 --- /dev/null +++ b/homeassistant/components/myneomitis/quality_scale.yaml @@ -0,0 +1,76 @@ +rules: + # Bronze tier rules + action-setup: + status: exempt + comment: Integration does not register service actions. + appropriate-polling: + status: exempt + comment: Integration uses WebSocket push updates, not polling. + 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: 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 tier rules + action-exceptions: + status: exempt + comment: Integration does not provide service actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration has no configuration parameters beyond initial setup. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: + status: exempt + comment: Integration uses WebSocket callbacks to push updates directly to entities, not coordinator-based polling. + reauthentication-flow: todo + test-coverage: done + + # Gold tier rules + devices: todo + diagnostics: todo + discovery-update-info: + status: exempt + comment: Integration is cloud-based and does not use local discovery. + discovery: + status: exempt + comment: Integration requires manual authentication via cloud service. + 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: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum tier rules + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/myneomitis/select.py b/homeassistant/components/myneomitis/select.py new file mode 100644 index 00000000000000..c2d70e70346dfb --- /dev/null +++ b/homeassistant/components/myneomitis/select.py @@ -0,0 +1,208 @@ +"""Select entities for MyNeomitis integration. + +This module defines and sets up the select entities for the MyNeomitis integration. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging +from typing import Any + +from pyaxencoapi import PyAxencoAPI + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import MyNeomitisConfigEntry +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SUPPORTED_MODELS: frozenset[str] = frozenset({"EWS"}) +SUPPORTED_SUB_MODELS: frozenset[str] = frozenset({"UFH"}) + +PRESET_MODE_MAP = { + "comfort": 1, + "eco": 2, + "antifrost": 3, + "standby": 4, + "boost": 6, + "setpoint": 8, + "comfort_plus": 20, + "eco_1": 40, + "eco_2": 41, + "auto": 60, +} + +PRESET_MODE_MAP_RELAIS = { + "on": 1, + "off": 2, + "auto": 60, +} + +PRESET_MODE_MAP_UFH = { + "heating": 0, + "cooling": 1, +} + +REVERSE_PRESET_MODE_MAP = {v: k for k, v in PRESET_MODE_MAP.items()} + +REVERSE_PRESET_MODE_MAP_RELAIS = {v: k for k, v in PRESET_MODE_MAP_RELAIS.items()} + +REVERSE_PRESET_MODE_MAP_UFH = {v: k for k, v in PRESET_MODE_MAP_UFH.items()} + + +@dataclass(frozen=True, kw_only=True) +class MyNeoSelectEntityDescription(SelectEntityDescription): + """Describe MyNeomitis select entity.""" + + preset_mode_map: dict[str, int] + reverse_preset_mode_map: dict[int, str] + state_key: str + + +SELECT_TYPES: dict[str, MyNeoSelectEntityDescription] = { + "relais": MyNeoSelectEntityDescription( + key="relais", + translation_key="relais", + options=list(PRESET_MODE_MAP_RELAIS), + preset_mode_map=PRESET_MODE_MAP_RELAIS, + reverse_preset_mode_map=REVERSE_PRESET_MODE_MAP_RELAIS, + state_key="targetMode", + ), + "pilote": MyNeoSelectEntityDescription( + key="pilote", + translation_key="pilote", + options=list(PRESET_MODE_MAP), + preset_mode_map=PRESET_MODE_MAP, + reverse_preset_mode_map=REVERSE_PRESET_MODE_MAP, + state_key="targetMode", + ), + "ufh": MyNeoSelectEntityDescription( + key="ufh", + translation_key="ufh", + options=list(PRESET_MODE_MAP_UFH), + preset_mode_map=PRESET_MODE_MAP_UFH, + reverse_preset_mode_map=REVERSE_PRESET_MODE_MAP_UFH, + state_key="changeOverUser", + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MyNeomitisConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Select entities from a config entry.""" + api = config_entry.runtime_data.api + devices = config_entry.runtime_data.devices + + def _create_entity(device: dict) -> MyNeoSelect: + """Create a select entity for a device.""" + if device["model"] == "EWS": + # According to the MyNeomitis API, EWS "relais" devices expose a "relayMode" + # field in their state, while "pilote" devices do not. We therefore use the + # presence of "relayMode" as an explicit heuristic to distinguish relais + # from pilote devices. If the upstream API changes this behavior, this + # detection logic must be revisited. + if "relayMode" in device.get("state", {}): + description = SELECT_TYPES["relais"] + else: + description = SELECT_TYPES["pilote"] + else: # UFH + description = SELECT_TYPES["ufh"] + + return MyNeoSelect(api, device, description) + + select_entities = [ + _create_entity(device) + for device in devices + if device["model"] in SUPPORTED_MODELS | SUPPORTED_SUB_MODELS + ] + + async_add_entities(select_entities) + + +class MyNeoSelect(SelectEntity): + """Select entity for MyNeomitis devices.""" + + entity_description: MyNeoSelectEntityDescription + _attr_has_entity_name = True + _attr_name = None # Entity represents the device itself + _attr_should_poll = False + + def __init__( + self, + api: PyAxencoAPI, + device: dict[str, Any], + description: MyNeoSelectEntityDescription, + ) -> None: + """Initialize the MyNeoSelect entity.""" + self.entity_description = description + self._api = api + self._device = device + self._attr_unique_id = device["_id"] + self._attr_available = device["connected"] + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, device["_id"])}, + name=device["name"], + manufacturer="Axenco", + model=device["model"], + ) + # Set current option based on device state + current_mode = device.get("state", {}).get(description.state_key) + self._attr_current_option = description.reverse_preset_mode_map.get( + current_mode + ) + self._unavailable_logged: bool = False + + 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: + """Handle WebSocket updates for the device.""" + if not new_state: + return + + if "connected" in new_state: + self._attr_available = new_state["connected"] + if not self._attr_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 + + # Check for state updates using the description's state_key + state_key = self.entity_description.state_key + if state_key in new_state: + mode = new_state.get(state_key) + if mode is not None: + self._attr_current_option = ( + self.entity_description.reverse_preset_mode_map.get(mode) + ) + + self.async_write_ha_state() + + async def async_select_option(self, option: str) -> None: + """Send the new mode via the API.""" + mode_code = self.entity_description.preset_mode_map.get(option) + + if mode_code is None: + _LOGGER.warning("Unknown mode selected: %s", option) + return + + await self._api.set_device_mode(self._device["_id"], mode_code) + self._attr_current_option = option + self.async_write_ha_state() diff --git a/homeassistant/components/myneomitis/strings.json b/homeassistant/components/myneomitis/strings.json new file mode 100644 index 00000000000000..59edeafd0ff2e7 --- /dev/null +++ b/homeassistant/components/myneomitis/strings.json @@ -0,0 +1,57 @@ +{ + "config": { + "abort": { + "already_configured": "This integration is already configured." + }, + "error": { + "cannot_connect": "Could not connect to the MyNeomitis service. Please try again later.", + "invalid_auth": "Authentication failed. Please check your email address and password.", + "unknown": "An unexpected error occurred. Please try again." + }, + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "Your email address used for your MyNeomitis account", + "password": "Your MyNeomitis account password" + }, + "description": "Enter your MyNeomitis account credentials.", + "title": "Connect to MyNeomitis" + } + } + }, + "entity": { + "select": { + "pilote": { + "state": { + "antifrost": "Frost protection", + "auto": "[%key:common::state::auto%]", + "boost": "Boost", + "comfort": "Comfort", + "comfort_plus": "Comfort +", + "eco": "Eco", + "eco_1": "Eco -1", + "eco_2": "Eco -2", + "setpoint": "Setpoint", + "standby": "[%key:common::state::standby%]" + } + }, + "relais": { + "state": { + "auto": "[%key:common::state::auto%]", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + }, + "ufh": { + "state": { + "cooling": "Cooling", + "heating": "Heating" + } + } + } + } +} diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py index a87b78b549ea2d..05e19d452a2144 100644 --- a/homeassistant/components/mysensors/const.py +++ b/homeassistant/components/mysensors/const.py @@ -33,7 +33,7 @@ CHILD_CALLBACK: str = "mysensors_child_callback_{}_{}_{}_{}" NODE_CALLBACK: str = "mysensors_node_callback_{}_{}" MYSENSORS_DISCOVERY: str = "mysensors_discovery_{}_{}" -MYSENSORS_NODE_DISCOVERY: str = "mysensors_node_discovery" +MYSENSORS_NODE_DISCOVERY: str = "mysensors_node_discovery_{}" TYPE: Final = "type" UPDATE_DELAY: float = 0.1 diff --git a/homeassistant/components/mysensors/helpers.py b/homeassistant/components/mysensors/helpers.py index 9ed41dfe4e9f25..3c9b841bdb339e 100644 --- a/homeassistant/components/mysensors/helpers.py +++ b/homeassistant/components/mysensors/helpers.py @@ -70,7 +70,7 @@ def discover_mysensors_node( discovered_nodes.add(node_id) async_dispatcher_send( hass, - MYSENSORS_NODE_DISCOVERY, + MYSENSORS_NODE_DISCOVERY.format(gateway_id), { ATTR_GATEWAY_ID: gateway_id, ATTR_NODE_ID: node_id, diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index 3793bed8af2ef7..c6fee7ba52a887 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -244,7 +244,7 @@ def async_node_discover(discovery_info: NodeDiscoveryInfo) -> None: config_entry.async_on_unload( async_dispatcher_connect( hass, - MYSENSORS_NODE_DISCOVERY, + MYSENSORS_NODE_DISCOVERY.format(config_entry.entry_id), async_node_discover, ), ) diff --git a/homeassistant/components/mystrom/binary_sensor.py b/homeassistant/components/mystrom/binary_sensor.py index 16772fc7073aa9..0e4d8db73f472a 100644 --- a/homeassistant/components/mystrom/binary_sensor.py +++ b/homeassistant/components/mystrom/binary_sensor.py @@ -36,7 +36,7 @@ class MyStromView(HomeAssistantView): def __init__(self, add_entities): """Initialize the myStrom URL endpoint.""" - self.buttons = {} + self.buttons: dict[str, MyStromBinarySensor] = {} self.add_entities = add_entities async def get(self, request): @@ -80,21 +80,10 @@ class MyStromBinarySensor(BinarySensorEntity): def __init__(self, button_id): """Initialize the myStrom Binary sensor.""" - self._button_id = button_id - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._button_id - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self._state + self._attr_name = button_id @callback def async_on_update(self, value): """Receive an update.""" - self._state = value + self._attr_is_on = value self.async_write_ha_state() diff --git a/homeassistant/components/nad/media_player.py b/homeassistant/components/nad/media_player.py index c1efa18f72b39d..2af8c607610086 100644 --- a/homeassistant/components/nad/media_player.py +++ b/homeassistant/components/nad/media_player.py @@ -198,8 +198,10 @@ def __init__(self, config): self._nad_receiver = NADReceiverTCP(config.get(CONF_HOST)) self._min_vol = (config[CONF_MIN_VOLUME] + 90) * 2 # from dB to nad vol (0-200) self._max_vol = (config[CONF_MAX_VOLUME] + 90) * 2 # from dB to nad vol (0-200) - self._volume_step = config[CONF_VOLUME_STEP] self._nad_volume = None + vol_range = self._max_vol - self._min_vol + if vol_range: + self._attr_volume_step = 2 * config[CONF_VOLUME_STEP] / vol_range self._source_list = self._nad_receiver.available_sources() def turn_off(self) -> None: @@ -210,14 +212,6 @@ def turn_on(self) -> None: """Turn the media player on.""" self._nad_receiver.power_on() - def volume_up(self) -> None: - """Step volume up in the configured increments.""" - self._nad_receiver.set_volume(self._nad_volume + 2 * self._volume_step) - - def volume_down(self) -> None: - """Step volume down in the configured increments.""" - self._nad_receiver.set_volume(self._nad_volume - 2 * self._volume_step) - def set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" nad_volume_to_set = int( diff --git a/homeassistant/components/nam/__init__.py b/homeassistant/components/nam/__init__.py index 03ad5118352499..4504cff42b3a92 100644 --- a/homeassistant/components/nam/__init__.py +++ b/homeassistant/components/nam/__init__.py @@ -12,7 +12,7 @@ NettigoAirMonitor, ) -from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM +from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -63,7 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NAMConfigEntry) -> bool: for sensor_type in ("sds", ATTR_SDS011, ATTR_SPS30): unique_id = f"{coordinator.unique_id}-{sensor_type}" if entity_id := ent_reg.async_get_entity_id( - AIR_QUALITY_PLATFORM, DOMAIN, unique_id + AIR_QUALITY_DOMAIN, DOMAIN, unique_id ): _LOGGER.debug("Removing deprecated air_quality entity %s", entity_id) ent_reg.async_remove(entity_id) diff --git a/homeassistant/components/nam/sensor.py b/homeassistant/components/nam/sensor.py index a7e5eb71912867..e59d111e5e553d 100644 --- a/homeassistant/components/nam/sensor.py +++ b/homeassistant/components/nam/sensor.py @@ -10,7 +10,7 @@ from nettigo_air_monitor import NAMSensors from homeassistant.components.sensor import ( - DOMAIN as PLATFORM, + DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -381,7 +381,9 @@ async def async_setup_entry( for old_sensor, new_sensor in MIGRATION_SENSORS: old_unique_id = f"{coordinator.unique_id}-{old_sensor}" new_unique_id = f"{coordinator.unique_id}-{new_sensor}" - if entity_id := ent_reg.async_get_entity_id(PLATFORM, DOMAIN, old_unique_id): + if entity_id := ent_reg.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, old_unique_id + ): _LOGGER.debug( "Migrating entity %s from old unique ID '%s' to new unique ID '%s'", entity_id, diff --git a/homeassistant/components/namecheapdns/manifest.json b/homeassistant/components/namecheapdns/manifest.json index cb8b708a2029aa..f02fef41b960be 100644 --- a/homeassistant/components/namecheapdns/manifest.json +++ b/homeassistant/components/namecheapdns/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/namecheapdns", "integration_type": "service", "iot_class": "cloud_push", + "quality_scale": "platinum", "requirements": [] } diff --git a/homeassistant/components/namecheapdns/quality_scale.yaml b/homeassistant/components/namecheapdns/quality_scale.yaml new file mode 100644 index 00000000000000..a3c9bb2f0dad4e --- /dev/null +++ b/homeassistant/components/namecheapdns/quality_scale.yaml @@ -0,0 +1,110 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: the integration has no actions + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: + status: exempt + comment: no external dependencies + docs-actions: + status: exempt + comment: the integration has no actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: integration has no entities + entity-unique-id: + status: exempt + comment: integration has no entities + has-entity-name: + status: exempt + comment: integration has no entities + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: the integration has no actions + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: the integration has no options + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: integration has no entities + integration-owner: done + log-when-unavailable: done + parallel-updates: + status: exempt + comment: integration has no entity platforms + reauthentication-flow: done + test-coverage: done + + # Gold + devices: + status: exempt + comment: integration has no devices + diagnostics: + status: exempt + comment: the integration has no runtime data and entry data only contains sensitive information + discovery-update-info: + status: exempt + comment: the service cannot be discovered + discovery: + status: exempt + comment: the service cannot be discovered + docs-data-update: done + docs-examples: + status: exempt + comment: the integration has no entities or actions + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: the integration is a service + docs-supported-functions: + status: exempt + comment: integration has no entities or actions + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: integration has no devices + entity-category: + status: exempt + comment: integration has no entities + entity-device-class: + status: exempt + comment: integration has no entities + entity-disabled-by-default: + status: exempt + comment: integration has no entities + entity-translations: + status: exempt + comment: integration has no entities + exception-translations: done + icon-translations: + status: exempt + comment: integration has no entities or actions + reconfiguration-flow: done + repair-issues: done + stale-devices: + status: exempt + comment: integration has no devices + + # Platinum + async-dependency: + status: exempt + comment: integration has no external dependencies + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/namecheapdns/strings.json b/homeassistant/components/namecheapdns/strings.json index 7685de9cf0db9b..da924a9faa331a 100644 --- a/homeassistant/components/namecheapdns/strings.json +++ b/homeassistant/components/namecheapdns/strings.json @@ -30,7 +30,7 @@ "password": "[%key:component::namecheapdns::config::step::user::data_description::password%]" }, "description": "You can find the Dynamic DNS password in your Namecheap account under [Domain List > {domain} > Manage > Advanced DNS > Dynamic DNS]({account_panel}).", - "title": "Re-configure {name}" + "title": "Reconfigure {name}" }, "user": { "data": { diff --git a/homeassistant/components/nasweb/alarm_control_panel.py b/homeassistant/components/nasweb/alarm_control_panel.py index 1c64eab0f07e2c..695c0168886dc6 100644 --- a/homeassistant/components/nasweb/alarm_control_panel.py +++ b/homeassistant/components/nasweb/alarm_control_panel.py @@ -9,7 +9,7 @@ from webio_api.const import STATE_ZONE_ALARM, STATE_ZONE_ARMED, STATE_ZONE_DISARMED from homeassistant.components.alarm_control_panel import ( - DOMAIN as DOMAIN_ALARM_CONTROL_PANEL, + DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, AlarmControlPanelEntity, AlarmControlPanelEntityFeature, AlarmControlPanelState, @@ -69,7 +69,7 @@ def _check_entities() -> None: for index in removed: unique_id = f"{DOMAIN}.{config.unique_id}.zone.{index}" if entity_id := entity_registry.async_get_entity_id( - DOMAIN_ALARM_CONTROL_PANEL, DOMAIN, unique_id + ALARM_CONTROL_PANEL_DOMAIN, DOMAIN, unique_id ): entity_registry.async_remove(entity_id) current_zones.remove(index) diff --git a/homeassistant/components/nasweb/sensor.py b/homeassistant/components/nasweb/sensor.py index e01e401b2ba9d4..82a69b74aa6c89 100644 --- a/homeassistant/components/nasweb/sensor.py +++ b/homeassistant/components/nasweb/sensor.py @@ -15,7 +15,7 @@ ) from homeassistant.components.sensor import ( - DOMAIN as DOMAIN_SENSOR, + DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorStateClass, @@ -70,7 +70,7 @@ def _check_entities() -> None: for index in removed: unique_id = f"{DOMAIN}.{config.unique_id}.input.{index}" if entity_id := entity_registry.async_get_entity_id( - DOMAIN_SENSOR, DOMAIN, unique_id + SENSOR_DOMAIN, DOMAIN, unique_id ): entity_registry.async_remove(entity_id) current_inputs.remove(index) diff --git a/homeassistant/components/nasweb/switch.py b/homeassistant/components/nasweb/switch.py index 06d3f57121e664..a36f3062932c2e 100644 --- a/homeassistant/components/nasweb/switch.py +++ b/homeassistant/components/nasweb/switch.py @@ -9,7 +9,7 @@ from webio_api import Output as NASwebOutput from webio_api.const import STATE_ENTITY_UNAVAILABLE, STATE_OUTPUT_OFF, STATE_OUTPUT_ON -from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH, SwitchEntity +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo @@ -71,7 +71,7 @@ def _check_entities() -> None: for index in removed: unique_id = f"{DOMAIN}.{config.unique_id}.relay_switch.{index}" if entity_id := entity_registry.async_get_entity_id( - DOMAIN_SWITCH, DOMAIN, unique_id + SWITCH_DOMAIN, DOMAIN, unique_id ): entity_registry.async_remove(entity_id) current_outputs.remove(index) diff --git a/homeassistant/components/nederlandse_spoorwegen/config_flow.py b/homeassistant/components/nederlandse_spoorwegen/config_flow.py index 34872509aea763..71c35facaf6d92 100644 --- a/homeassistant/components/nederlandse_spoorwegen/config_flow.py +++ b/homeassistant/components/nederlandse_spoorwegen/config_flow.py @@ -17,7 +17,6 @@ ConfigEntry, ConfigFlow, ConfigFlowResult, - ConfigSubentryData, ConfigSubentryFlow, SubentryFlowResult, ) @@ -30,15 +29,7 @@ TimeSelector, ) -from .const import ( - CONF_FROM, - CONF_ROUTES, - CONF_TIME, - CONF_TO, - CONF_VIA, - DOMAIN, - INTEGRATION_TITLE, -) +from .const import CONF_FROM, CONF_TIME, CONF_TO, CONF_VIA, DOMAIN, INTEGRATION_TITLE _LOGGER = logging.getLogger(__name__) @@ -133,47 +124,6 @@ async def async_step_reconfigure( errors=errors, ) - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Handle import from YAML configuration.""" - self._async_abort_entries_match({CONF_API_KEY: import_data[CONF_API_KEY]}) - - client = NSAPI(import_data[CONF_API_KEY]) - try: - stations = await self.hass.async_add_executor_job(client.get_stations) - except HTTPError: - return self.async_abort(reason="invalid_auth") - except RequestsConnectionError, Timeout: - return self.async_abort(reason="cannot_connect") - except Exception: - _LOGGER.exception("Unexpected exception validating API key") - return self.async_abort(reason="unknown") - - station_codes = {station.code for station in stations} - - subentries: list[ConfigSubentryData] = [] - for route in import_data.get(CONF_ROUTES, []): - # Convert station codes to uppercase for consistency with UI routes - for key in (CONF_FROM, CONF_TO, CONF_VIA): - if key in route: - route[key] = route[key].upper() - if route[key] not in station_codes: - return self.async_abort(reason="invalid_station") - - subentries.append( - ConfigSubentryData( - title=route[CONF_NAME], - subentry_type="route", - data=route, - unique_id=None, - ) - ) - - return self.async_create_entry( - title=INTEGRATION_TITLE, - data={CONF_API_KEY: import_data[CONF_API_KEY]}, - subentries=subentries, - ) - @classmethod @callback def async_get_supported_subentry_types( diff --git a/homeassistant/components/nederlandse_spoorwegen/const.py b/homeassistant/components/nederlandse_spoorwegen/const.py index e3af02d12a0f65..19aed623d0c3a0 100644 --- a/homeassistant/components/nederlandse_spoorwegen/const.py +++ b/homeassistant/components/nederlandse_spoorwegen/const.py @@ -12,7 +12,6 @@ # Update every 2 minutes SCAN_INTERVAL = timedelta(minutes=2) -CONF_ROUTES = "routes" CONF_FROM = "from" CONF_TO = "to" CONF_VIA = "via" diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index d1692c72725e5a..712a020684cc05 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -5,42 +5,24 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime -import logging from typing import Any from ns_api import Trip -import voluptuous as vol from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_API_KEY, CONF_NAME, EntityCategory -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.const import EntityCategory +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import ( - AddConfigEntryEntitiesCallback, - AddEntitiesCallback, -) -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .binary_sensor import get_delay -from .const import ( - CONF_FROM, - CONF_ROUTES, - CONF_TIME, - CONF_TO, - CONF_VIA, - DOMAIN, - INTEGRATION_TITLE, - ROUTE_MODEL, -) +from .const import DOMAIN, INTEGRATION_TITLE, ROUTE_MODEL from .coordinator import NSConfigEntry, NSDataUpdateCoordinator @@ -70,26 +52,9 @@ def _get_route(trip: Trip | None) -> list[str]: "CANCELLED": "cancelled", } -_LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 # since we use coordinator pattern -ROUTE_SCHEMA = vol.Schema( - { - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_FROM): cv.string, - vol.Required(CONF_TO): cv.string, - vol.Optional(CONF_VIA): cv.string, - vol.Optional(CONF_TIME): cv.time, - } -) - -ROUTES_SCHEMA = vol.All(cv.ensure_list, [ROUTE_SCHEMA]) - -PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_API_KEY): cv.string, vol.Optional(CONF_ROUTES): ROUTES_SCHEMA} -) - @dataclass(frozen=True, kw_only=True) class NSSensorEntityDescription(SensorEntityDescription): @@ -195,55 +160,6 @@ class NSSensorEntityDescription(SensorEntityDescription): ) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the departure sensor.""" - - 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.4.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key=f"deprecated_yaml_import_issue_{result.get('reason')}", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": INTEGRATION_TITLE, - }, - ) - return - - ir.async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2026.4.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": INTEGRATION_TITLE, - }, - ) - - async def async_setup_entry( hass: HomeAssistant, config_entry: NSConfigEntry, diff --git a/homeassistant/components/nederlandse_spoorwegen/strings.json b/homeassistant/components/nederlandse_spoorwegen/strings.json index 0783e4c5a97084..50eef378da737f 100644 --- a/homeassistant/components/nederlandse_spoorwegen/strings.json +++ b/homeassistant/components/nederlandse_spoorwegen/strings.json @@ -127,23 +127,5 @@ "name": "Transfers" } } - }, - "issues": { - "deprecated_yaml_import_issue_cannot_connect": { - "description": "Configuring Nederlandse Spoorwegen using YAML sensor platform is deprecated.\n\nWhile importing your configuration, Home Assistant could not connect to the NS API. Please check your internet connection and the status of the NS API, then restart Home Assistant to try again, or remove the existing YAML configuration and set the integration up via the UI.", - "title": "[%key:component::nederlandse_spoorwegen::issues::deprecated_yaml_import_issue_invalid_auth::title%]" - }, - "deprecated_yaml_import_issue_invalid_auth": { - "description": "Configuring Nederlandse Spoorwegen using YAML sensor platform is deprecated.\n\nWhile importing your configuration, an invalid API key was found. Please update your YAML configuration, or remove the existing YAML configuration and set the integration up via the UI.", - "title": "Nederlandse Spoorwegen YAML configuration deprecated" - }, - "deprecated_yaml_import_issue_invalid_station": { - "description": "Configuring Nederlandse Spoorwegen using YAML sensor platform is deprecated.\n\nWhile importing your configuration an invalid station was found. Please update your YAML configuration, or remove the existing YAML configuration and set the integration up via the UI.", - "title": "[%key:component::nederlandse_spoorwegen::issues::deprecated_yaml_import_issue_invalid_auth::title%]" - }, - "deprecated_yaml_import_issue_unknown": { - "description": "Configuring Nederlandse Spoorwegen using YAML sensor platform is deprecated.\n\nWhile importing your configuration, an unknown error occurred. Please restart Home Assistant to try again, or remove the existing YAML configuration and set the integration up via the UI.", - "title": "[%key:component::nederlandse_spoorwegen::issues::deprecated_yaml_import_issue_invalid_auth::title%]" - } } } diff --git a/homeassistant/components/ness_alarm/__init__.py b/homeassistant/components/ness_alarm/__init__.py index f9ed94a014bf32..4036086fe0fb56 100644 --- a/homeassistant/components/ness_alarm/__init__.py +++ b/homeassistant/components/ness_alarm/__init__.py @@ -1,6 +1,7 @@ """Support for Ness D8X/D16X devices.""" -import datetime +from __future__ import annotations + import logging from typing import NamedTuple @@ -9,41 +10,41 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA as BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, - BinarySensorDeviceClass, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( - ATTR_CODE, - ATTR_STATE, CONF_HOST, + CONF_PORT, CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_STOP, - Platform, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, Event, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType -from homeassistant.util.hass_dict import HassKey - -_LOGGER = logging.getLogger(__name__) -DOMAIN = "ness_alarm" -DATA_NESS: HassKey[Client] = HassKey(DOMAIN) +from .const import ( + CONF_INFER_ARMING_STATE, + CONF_ZONE_ID, + CONF_ZONE_NAME, + CONF_ZONE_TYPE, + CONF_ZONES, + DEFAULT_SCAN_INTERVAL, + DEFAULT_ZONE_TYPE, + DOMAIN, + PLATFORMS, + SIGNAL_ARMING_STATE_CHANGED, + SIGNAL_ZONE_CHANGED, +) +from .services import async_setup_services -CONF_DEVICE_PORT = "port" -CONF_INFER_ARMING_STATE = "infer_arming_state" -CONF_ZONES = "zones" -CONF_ZONE_NAME = "name" -CONF_ZONE_TYPE = "type" -CONF_ZONE_ID = "id" -ATTR_OUTPUT_ID = "output_id" -DEFAULT_SCAN_INTERVAL = datetime.timedelta(minutes=1) -DEFAULT_INFER_ARMING_STATE = False +_LOGGER = logging.getLogger(__name__) -SIGNAL_ZONE_CHANGED = "ness_alarm.zone_changed" -SIGNAL_ARMING_STATE_CHANGED = "ness_alarm.arming_state_changed" +type NessAlarmConfigEntry = ConfigEntry[Client] class ZoneChangedData(NamedTuple): @@ -53,7 +54,6 @@ class ZoneChangedData(NamedTuple): state: bool -DEFAULT_ZONE_TYPE = BinarySensorDeviceClass.MOTION ZONE_SCHEMA = vol.Schema( { vol.Required(CONF_ZONE_NAME): cv.string, @@ -64,88 +64,111 @@ class ZoneChangedData(NamedTuple): } ) +# YAML configuration is deprecated but supported for import CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( { vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_DEVICE_PORT): cv.port, + vol.Required(CONF_PORT): cv.port, vol.Optional( CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL ): cv.positive_time_period, vol.Optional(CONF_ZONES, default=[]): vol.All( cv.ensure_list, [ZONE_SCHEMA] ), - vol.Optional( - CONF_INFER_ARMING_STATE, default=DEFAULT_INFER_ARMING_STATE - ): cv.boolean, + vol.Optional(CONF_INFER_ARMING_STATE, default=False): cv.boolean, } ) }, extra=vol.ALLOW_EXTRA, ) -SERVICE_PANIC = "panic" -SERVICE_AUX = "aux" - -SERVICE_SCHEMA_PANIC = vol.Schema({vol.Required(ATTR_CODE): cv.string}) -SERVICE_SCHEMA_AUX = vol.Schema( - { - vol.Required(ATTR_OUTPUT_ID): cv.positive_int, - vol.Optional(ATTR_STATE, default=True): cv.boolean, - } -) - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Ness Alarm platform.""" + async_setup_services(hass) + if DOMAIN not in config: + return True - conf = config[DOMAIN] + hass.async_create_task(_async_setup(hass, config)) - zones = conf[CONF_ZONES] - host = conf[CONF_HOST] - port = conf[CONF_DEVICE_PORT] - scan_interval = conf[CONF_SCAN_INTERVAL] - infer_arming_state = conf[CONF_INFER_ARMING_STATE] + return True - client = Client( - host=host, - port=port, - update_interval=scan_interval.total_seconds(), - infer_arming_state=infer_arming_state, - ) - hass.data[DATA_NESS] = client - async def _close(event): - await client.close() +async def _async_setup(hass: HomeAssistant, config: ConfigType) -> None: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config[DOMAIN], + ) + if ( + result.get("type") is FlowResultType.ABORT + and result.get("reason") != "already_configured" + ): + async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_{result.get('reason')}", + breaks_in_ha_version="2026.9.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_{result.get('reason')}", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Ness Alarm", + }, + ) + return + + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2026.9.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Ness Alarm", + }, + ) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close) - async def _started(event): - # Force update for current arming status and current zone states (once Home Assistant has finished loading required sensors and panel) - _LOGGER.debug("invoking client keepalive() & update()") - hass.loop.create_task(client.keepalive()) - hass.loop.create_task(client.update()) +async def async_setup_entry(hass: HomeAssistant, entry: NessAlarmConfigEntry) -> bool: + """Set up Ness Alarm from a config entry.""" + client = Client( + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + update_interval=DEFAULT_SCAN_INTERVAL.total_seconds(), + infer_arming_state=entry.data.get(CONF_INFER_ARMING_STATE, False), + ) - async_at_started(hass, _started) + # Verify the client can connect to the alarm panel + try: + await client.update() + except OSError as err: + await client.close() + raise ConfigEntryNotReady( + f"Unable to connect to alarm panel at" + f" {entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}" + ) from err - hass.async_create_task( - async_load_platform( - hass, Platform.BINARY_SENSOR, DOMAIN, {CONF_ZONES: zones}, config - ) - ) - hass.async_create_task( - async_load_platform(hass, Platform.ALARM_CONTROL_PANEL, DOMAIN, {}, config) - ) + entry.runtime_data = client - def on_zone_change(zone_id: int, state: bool): - """Receives and propagates zone state updates.""" + def on_zone_change(zone_id: int, state: bool) -> None: + """Receive and propagate zone state updates.""" async_dispatcher_send( hass, SIGNAL_ZONE_CHANGED, ZoneChangedData(zone_id=zone_id, state=state) ) - def on_state_change(arming_state: ArmingState, arming_mode: ArmingMode | None): - """Receives and propagates arming state updates.""" + def on_state_change( + arming_state: ArmingState, arming_mode: ArmingMode | None + ) -> None: + """Receive and propagate arming state updates.""" async_dispatcher_send( hass, SIGNAL_ARMING_STATE_CHANGED, arming_state, arming_mode ) @@ -153,17 +176,37 @@ def on_state_change(arming_state: ArmingState, arming_mode: ArmingMode | None): client.on_zone_change(on_zone_change) client.on_state_change(on_state_change) - async def handle_panic(call: ServiceCall) -> None: - await client.panic(call.data[ATTR_CODE]) + async def _close(event: Event) -> None: + await client.close() - async def handle_aux(call: ServiceCall) -> None: - await client.aux(call.data[ATTR_OUTPUT_ID], call.data[ATTR_STATE]) + entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close)) - hass.services.async_register( - DOMAIN, SERVICE_PANIC, handle_panic, schema=SERVICE_SCHEMA_PANIC - ) - hass.services.async_register( - DOMAIN, SERVICE_AUX, handle_aux, schema=SERVICE_SCHEMA_AUX - ) + async def _started(hass: HomeAssistant) -> None: + _LOGGER.debug("Invoking client keepalive() & update()") + hass.async_create_task(client.keepalive()) + hass.async_create_task(client.update()) + + async_at_started(hass, _started) + + # Forward to platforms + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + # Register update listener for options + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) return True + + +async def async_unload_entry(hass: HomeAssistant, entry: NessAlarmConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + await entry.runtime_data.close() + + return unload_ok + + +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload config entry when options change.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/ness_alarm/alarm_control_panel.py b/homeassistant/components/ness_alarm/alarm_control_panel.py index 64b764c6872628..d9f8d9db3b179a 100644 --- a/homeassistant/components/ness_alarm/alarm_control_panel.py +++ b/homeassistant/components/ness_alarm/alarm_control_panel.py @@ -13,11 +13,12 @@ CodeFormat, ) 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 AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DATA_NESS, SIGNAL_ARMING_STATE_CHANGED +from . import SIGNAL_ARMING_STATE_CHANGED, NessAlarmConfigEntry +from .const import CONF_SHOW_HOME_MODE, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -31,18 +32,18 @@ } -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + entry: NessAlarmConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the Ness Alarm alarm control panel devices.""" - if discovery_info is None: - return + """Set up the Ness Alarm alarm control panel from config entry.""" + client = entry.runtime_data + show_home_mode = entry.options.get(CONF_SHOW_HOME_MODE, True) - device = NessAlarmPanel(hass.data[DATA_NESS], "Alarm Panel") - async_add_entities([device]) + async_add_entities( + [NessAlarmPanel(client, entry.entry_id, show_home_mode)], + ) class NessAlarmPanel(AlarmControlPanelEntity): @@ -50,16 +51,23 @@ class NessAlarmPanel(AlarmControlPanelEntity): _attr_code_format = CodeFormat.NUMBER _attr_should_poll = False - _attr_supported_features = ( - AlarmControlPanelEntityFeature.ARM_HOME - | AlarmControlPanelEntityFeature.ARM_AWAY - | AlarmControlPanelEntityFeature.TRIGGER - ) - def __init__(self, client: Client, name: str) -> None: + def __init__(self, client: Client, entry_id: str, show_home_mode: bool) -> None: """Initialize the alarm panel.""" self._client = client - self._attr_name = name + self._attr_name = "Alarm Panel" + self._attr_unique_id = f"{entry_id}_alarm_panel" + self._attr_device_info = DeviceInfo( + name="Alarm Panel", + identifiers={(DOMAIN, f"{entry_id}_alarm_panel")}, + ) + features = ( + AlarmControlPanelEntityFeature.ARM_AWAY + | AlarmControlPanelEntityFeature.TRIGGER + ) + if show_home_mode: + features |= AlarmControlPanelEntityFeature.ARM_HOME + self._attr_supported_features = features async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/ness_alarm/binary_sensor.py b/homeassistant/components/ness_alarm/binary_sensor.py index 8feaa6c696b44c..1058f69e37ecdf 100644 --- a/homeassistant/components/ness_alarm/binary_sensor.py +++ b/homeassistant/components/ness_alarm/binary_sensor.py @@ -6,41 +6,53 @@ BinarySensorDeviceClass, BinarySensorEntity, ) +from homeassistant.const import CONF_TYPE from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import ( - CONF_ZONE_ID, +from . import SIGNAL_ZONE_CHANGED, NessAlarmConfigEntry, ZoneChangedData +from .const import ( CONF_ZONE_NAME, - CONF_ZONE_TYPE, - CONF_ZONES, - SIGNAL_ZONE_CHANGED, - ZoneChangedData, + CONF_ZONE_NUMBER, + DEFAULT_ZONE_TYPE, + DOMAIN, + SUBENTRY_TYPE_ZONE, ) -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + entry: NessAlarmConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the Ness Alarm binary sensor devices.""" - if not discovery_info: - return + """Set up the Ness Alarm binary sensor from config entry.""" + # Get zone subentries + zone_subentries = filter( + lambda subentry: subentry.subentry_type == SUBENTRY_TYPE_ZONE, + entry.subentries.values(), + ) - configured_zones = discovery_info[CONF_ZONES] + # Create entities from zone subentries + for subentry in zone_subentries: + zone_num: int = subentry.data[CONF_ZONE_NUMBER] + zone_type: BinarySensorDeviceClass = subentry.data.get( + CONF_TYPE, DEFAULT_ZONE_TYPE + ) + zone_name: str | None = subentry.data.get(CONF_ZONE_NAME) - async_add_entities( - NessZoneBinarySensor( - zone_id=zone_config[CONF_ZONE_ID], - name=zone_config[CONF_ZONE_NAME], - zone_type=zone_config[CONF_ZONE_TYPE], + async_add_entities( + [ + NessZoneBinarySensor( + zone_id=zone_num, + zone_type=zone_type, + entry_id=entry.entry_id, + zone_name=zone_name, + ) + ], + config_subentry_id=subentry.subentry_id, ) - for zone_config in configured_zones - ) class NessZoneBinarySensor(BinarySensorEntity): @@ -49,13 +61,22 @@ class NessZoneBinarySensor(BinarySensorEntity): _attr_should_poll = False def __init__( - self, zone_id: int, name: str, zone_type: BinarySensorDeviceClass + self, + zone_id: int, + zone_type: BinarySensorDeviceClass, + entry_id: str, + zone_name: str | None = None, ) -> None: """Initialize the binary_sensor.""" self._zone_id = zone_id - self._attr_name = name self._attr_device_class = zone_type self._attr_is_on = False + self._attr_unique_id = f"{entry_id}_zone_{zone_id}" + self._attr_name = f"Zone {zone_id}" + self._attr_device_info = DeviceInfo( + name=zone_name or f"Zone {zone_id}", + identifiers={(DOMAIN, self._attr_unique_id)}, + ) async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/ness_alarm/config_flow.py b/homeassistant/components/ness_alarm/config_flow.py new file mode 100644 index 00000000000000..1cbc11f3320c5c --- /dev/null +++ b/homeassistant/components/ness_alarm/config_flow.py @@ -0,0 +1,294 @@ +"""Config flow for Ness Alarm integration.""" + +from __future__ import annotations + +import asyncio +import logging +from types import MappingProxyType +from typing import Any + +from nessclient import Client +import voluptuous as vol + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryData, + ConfigSubentryFlow, + OptionsFlow, + SubentryFlowResult, +) +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv, selector + +from .const import ( + CONF_INFER_ARMING_STATE, + CONF_SHOW_HOME_MODE, + CONF_ZONE_ID, + CONF_ZONE_NAME, + CONF_ZONE_NUMBER, + CONF_ZONE_TYPE, + CONF_ZONES, + CONNECTION_TIMEOUT, + DEFAULT_INFER_ARMING_STATE, + DEFAULT_PORT, + DEFAULT_ZONE_TYPE, + DOMAIN, + POST_CONNECTION_DELAY, + SUBENTRY_TYPE_ZONE, +) + +_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_INFER_ARMING_STATE, default=DEFAULT_INFER_ARMING_STATE): bool, + } +) + +ZONE_SCHEMA = vol.Schema( + { + vol.Required(CONF_TYPE, default=DEFAULT_ZONE_TYPE): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[cls.value for cls in BinarySensorDeviceClass], + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="binary_sensor_device_class", + sort=True, + ), + ), + } +) + + +class NessAlarmConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Ness Alarm.""" + + VERSION = 1 + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return { + SUBENTRY_TYPE_ZONE: ZoneSubentryFlowHandler, + } + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> OptionsFlow: + """Create the options flow.""" + return NessAlarmOptionsFlowHandler() + + async def _test_connection(self, host: str, port: int) -> None: + """Test connection to the alarm panel. + + Raises OSError on connection failure. + """ + client = Client(host=host, port=port) + try: + await asyncio.wait_for(client.update(), timeout=CONNECTION_TIMEOUT) + except TimeoutError as err: + raise OSError(f"Timed out connecting to {host}:{port}") from err + finally: + await client.close() + + 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: + host = user_input[CONF_HOST] + port = user_input[CONF_PORT] + + # Check if already configured + self._async_abort_entries_match({CONF_HOST: host}) + + # Test connection to the alarm panel + try: + await self._test_connection(host, port) + except OSError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error connecting to %s:%s", host, port) + errors["base"] = "unknown" + + if not errors: + # Brief delay to ensure the panel releases the test connection + await asyncio.sleep(POST_CONNECTION_DELAY) + return self.async_create_entry( + title=f"Ness Alarm {host}:{port}", + 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_data: dict[str, Any]) -> ConfigFlowResult: + """Import YAML configuration.""" + host = import_data[CONF_HOST] + port = import_data[CONF_PORT] + + # Check if already configured + self._async_abort_entries_match({CONF_HOST: host}) + + # Test connection to the alarm panel + try: + await self._test_connection(host, port) + except OSError: + return self.async_abort(reason="cannot_connect") + except Exception: + _LOGGER.exception( + "Unexpected error connecting to %s:%s during import", host, port + ) + return self.async_abort(reason="unknown") + + # Brief delay to ensure the panel releases the test connection + await asyncio.sleep(POST_CONNECTION_DELAY) + + # Prepare subentries for zones + subentries: list[ConfigSubentryData] = [] + zones = import_data.get(CONF_ZONES, []) + + for zone_config in zones: + zone_id = zone_config[CONF_ZONE_ID] + zone_name = zone_config.get(CONF_ZONE_NAME) + zone_type = zone_config.get(CONF_ZONE_TYPE, DEFAULT_ZONE_TYPE) + + # Subentry title is always "Zone {zone_id}" + title = f"Zone {zone_id}" + + # Build subentry data + subentry_data = { + CONF_ZONE_NUMBER: zone_id, + CONF_TYPE: zone_type, + } + # Include zone name in data if provided (for device naming) + if zone_name: + subentry_data[CONF_ZONE_NAME] = zone_name + + subentries.append( + { + "subentry_type": SUBENTRY_TYPE_ZONE, + "title": title, + "unique_id": f"{SUBENTRY_TYPE_ZONE}_{zone_id}", + "data": MappingProxyType(subentry_data), + } + ) + + return self.async_create_entry( + title=f"Ness Alarm {host}:{port}", + data={ + CONF_HOST: host, + CONF_PORT: port, + CONF_INFER_ARMING_STATE: import_data.get( + CONF_INFER_ARMING_STATE, DEFAULT_INFER_ARMING_STATE + ), + }, + subentries=subentries, + ) + + +class NessAlarmOptionsFlowHandler(OptionsFlow): + """Handle options flow for Ness Alarm.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_SHOW_HOME_MODE, default=True): bool, + } + ), + self.config_entry.options, + ), + ) + + +class ZoneSubentryFlowHandler(ConfigSubentryFlow): + """Handle subentry flow for adding and modifying a zone.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to add new zone.""" + errors: dict[str, str] = {} + + if user_input is not None: + zone_number = int(user_input[CONF_ZONE_NUMBER]) + unique_id = f"{SUBENTRY_TYPE_ZONE}_{zone_number}" + + # Check if zone already exists + for existing_subentry in self._get_entry().subentries.values(): + if existing_subentry.unique_id == unique_id: + errors[CONF_ZONE_NUMBER] = "already_configured" + + if not errors: + # Store zone_number as int in data + user_input[CONF_ZONE_NUMBER] = zone_number + return self.async_create_entry( + title=f"Zone {zone_number}", + data=user_input, + unique_id=unique_id, + ) + + return self.async_show_form( + step_id="user", + errors=errors, + data_schema=vol.Schema( + { + vol.Required(CONF_ZONE_NUMBER): selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, + max=32, + mode=selector.NumberSelectorMode.BOX, + ) + ), + } + ).extend(ZONE_SCHEMA.schema), + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Reconfigure existing zone.""" + subconfig_entry = self._get_reconfigure_subentry() + + if user_input is not None: + return self.async_update_and_abort( + self._get_entry(), + subconfig_entry, + title=f"Zone {subconfig_entry.data[CONF_ZONE_NUMBER]}", + data_updates=user_input, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + ZONE_SCHEMA, + subconfig_entry.data, + ), + description_placeholders={ + CONF_ZONE_NUMBER: str(subconfig_entry.data[CONF_ZONE_NUMBER]) + }, + ) diff --git a/homeassistant/components/ness_alarm/const.py b/homeassistant/components/ness_alarm/const.py new file mode 100644 index 00000000000000..e18c1ae946bda6 --- /dev/null +++ b/homeassistant/components/ness_alarm/const.py @@ -0,0 +1,42 @@ +"""Constants for the Ness Alarm integration.""" + +from datetime import timedelta + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.const import Platform + +DOMAIN = "ness_alarm" + +# Platforms +PLATFORMS = [Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR] + +# Configuration constants +CONF_INFER_ARMING_STATE = "infer_arming_state" +CONF_ZONES = "zones" +CONF_ZONE_NAME = "name" +CONF_ZONE_TYPE = "type" +CONF_ZONE_ID = "id" +CONF_ZONE_NUMBER = "zone_number" +CONF_SHOW_HOME_MODE = "show_home_mode" + +# Subentry types +SUBENTRY_TYPE_ZONE = "zone" + +# Defaults +DEFAULT_PORT = 4999 +DEFAULT_SCAN_INTERVAL = timedelta(seconds=5) +DEFAULT_INFER_ARMING_STATE = False +DEFAULT_ZONE_TYPE = BinarySensorDeviceClass.MOTION + +# Connection +CONNECTION_TIMEOUT = 5 +POST_CONNECTION_DELAY = 1 + +# Signals +SIGNAL_ZONE_CHANGED = "ness_alarm.zone_changed" +SIGNAL_ARMING_STATE_CHANGED = "ness_alarm.arming_state_changed" + +# Services +SERVICE_PANIC = "panic" +SERVICE_AUX = "aux" +ATTR_OUTPUT_ID = "output_id" diff --git a/homeassistant/components/ness_alarm/manifest.json b/homeassistant/components/ness_alarm/manifest.json index 0b032fc24f6b07..600a1430d3734a 100644 --- a/homeassistant/components/ness_alarm/manifest.json +++ b/homeassistant/components/ness_alarm/manifest.json @@ -1,7 +1,8 @@ { "domain": "ness_alarm", "name": "Ness Alarm", - "codeowners": ["@nickw444"], + "codeowners": ["@nickw444", "@poshy163"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ness_alarm", "iot_class": "local_push", "loggers": ["nessclient"], diff --git a/homeassistant/components/ness_alarm/services.py b/homeassistant/components/ness_alarm/services.py new file mode 100644 index 00000000000000..a20c3b7a5d35c4 --- /dev/null +++ b/homeassistant/components/ness_alarm/services.py @@ -0,0 +1,53 @@ +"""Services for the Ness Alarm integration.""" + +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.const import ATTR_CODE, ATTR_STATE +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv + +from .const import ATTR_OUTPUT_ID, DOMAIN, SERVICE_AUX, SERVICE_PANIC + +SERVICE_SCHEMA_PANIC = vol.Schema({vol.Required(ATTR_CODE): cv.string}) +SERVICE_SCHEMA_AUX = vol.Schema( + { + vol.Required(ATTR_OUTPUT_ID): cv.positive_int, + vol.Optional(ATTR_STATE, default=True): cv.boolean, + } +) + + +def async_setup_services(hass: HomeAssistant) -> None: + """Register Ness Alarm services.""" + + async def handle_panic(call: ServiceCall) -> None: + """Handle panic service call.""" + entries = call.hass.config_entries.async_loaded_entries(DOMAIN) + if not entries: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_config_entry", + ) + client = entries[0].runtime_data + await client.panic(call.data[ATTR_CODE]) + + async def handle_aux(call: ServiceCall) -> None: + """Handle aux service call.""" + entries = call.hass.config_entries.async_loaded_entries(DOMAIN) + if not entries: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_config_entry", + ) + client = entries[0].runtime_data + await client.aux(call.data[ATTR_OUTPUT_ID], call.data[ATTR_STATE]) + + hass.services.async_register( + DOMAIN, SERVICE_PANIC, handle_panic, schema=SERVICE_SCHEMA_PANIC + ) + hass.services.async_register( + DOMAIN, SERVICE_AUX, handle_aux, schema=SERVICE_SCHEMA_AUX + ) diff --git a/homeassistant/components/ness_alarm/strings.json b/homeassistant/components/ness_alarm/strings.json index 94e1cd9a560dd4..dea09e2dd6100d 100644 --- a/homeassistant/components/ness_alarm/strings.json +++ b/homeassistant/components/ness_alarm/strings.json @@ -1,4 +1,91 @@ { + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "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": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "infer_arming_state": "Infer arming state", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The IP address or hostname of your Ness alarm panel.", + "infer_arming_state": "Attempt to infer the arming state from zone activity.", + "port": "The port on which the Ness alarm panel is accessible." + }, + "description": "Configure connection to your Ness D8X/D16X alarm panel.", + "title": "Set up Ness Alarm" + } + } + }, + "config_subentries": { + "zone": { + "entry_type": "Zone", + "error": { + "already_configured": "Zone with this number is already configured" + }, + "initiate_flow": { + "user": "Add zone" + }, + "step": { + "reconfigure": { + "data": { + "type": "[%key:component::ness_alarm::config_subentries::zone::step::user::data::type%]" + }, + "data_description": { + "type": "[%key:component::ness_alarm::config_subentries::zone::step::user::data_description::type%]" + }, + "title": "Reconfigure zone {zone_number}" + }, + "user": { + "data": { + "type": "Zone type", + "zone_number": "Zone number" + }, + "data_description": { + "type": "Choose the device class you would like the sensor to show as", + "zone_number": "Enter zone number to configure (1-32)" + }, + "title": "Configure zone" + } + } + } + }, + "exceptions": { + "no_config_entry": { + "message": "No Ness Alarm configuration entry is loaded" + } + }, + "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": "The {integration_title} YAML configuration is being removed" + }, + "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 unknown error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.", + "title": "The {integration_title} YAML configuration is being removed" + } + }, + "options": { + "step": { + "init": { + "data": { + "show_home_mode": "Show arm home mode" + }, + "data_description": { + "show_home_mode": "Enable this to show the arm home option on the alarm panel." + } + } + } + }, "services": { "aux": { "description": "Changes the state of an aux output.", diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 794b481618e7b0..d3cf1dedb9e2a0 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -7,7 +7,7 @@ from http import HTTPStatus import logging -from aiohttp import ClientError, ClientResponseError, web +from aiohttp import ClientError, web from google_nest_sdm.camera_traits import CameraClipPreviewTrait from google_nest_sdm.device import Device from google_nest_sdm.device_manager import DeviceManager @@ -43,6 +43,8 @@ ConfigEntryAuthFailed, ConfigEntryNotReady, HomeAssistantError, + OAuth2TokenRequestError, + OAuth2TokenRequestReauthError, Unauthorized, ) from homeassistant.helpers import ( @@ -253,11 +255,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool auth = await api.new_auth(hass, entry) try: await auth.async_get_access_token() - except ClientResponseError as err: - if 400 <= err.status < 500: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, translation_key="reauth_required" - ) from err + except OAuth2TokenRequestReauthError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="reauth_required" + ) from err + except OAuth2TokenRequestError as err: raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="auth_server_error" ) from err diff --git a/homeassistant/components/nest/media_source.py b/homeassistant/components/nest/media_source.py index a3d2901e91153e..4c7eb87636cf25 100644 --- a/homeassistant/components/nest/media_source.py +++ b/homeassistant/components/nest/media_source.py @@ -24,6 +24,7 @@ import logging import os import pathlib +import shutil from typing import Any from google_nest_sdm.camera_traits import CameraClipPreviewTrait, CameraEventImageTrait @@ -70,7 +71,8 @@ # Buffer writes every few minutes (plus guaranteed to be written at shutdown) STORAGE_SAVE_DELAY_SECONDS = 120 # Path under config directory -MEDIA_PATH = f"{DOMAIN}/event_media" +LEGACY_MEDIA_PATH = f"{DOMAIN}/event_media" +MEDIA_CACHE_PATH = "event_media" # Size of small in-memory disk cache to avoid excessive disk reads DISK_READ_LRU_MAX_SIZE = 32 @@ -83,19 +85,39 @@ async def async_get_media_event_store( hass: HomeAssistant, subscriber: GoogleNestSubscriber ) -> EventMediaStore: """Create the disk backed EventMediaStore.""" - media_path = hass.config.path(MEDIA_PATH) - - def mkdir() -> None: - os.makedirs(media_path, exist_ok=True) - - await hass.async_add_executor_job(mkdir) + media_path = pathlib.Path(hass.config.cache_path(DOMAIN, MEDIA_CACHE_PATH)) + legacy_media_path = pathlib.Path(hass.config.path(LEGACY_MEDIA_PATH)) + await hass.async_add_executor_job( + _prepare_media_cache_dir, media_path, legacy_media_path + ) store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY, private=True) - return NestEventMediaStore(hass, subscriber, store, media_path) + return NestEventMediaStore(hass, subscriber, store, str(media_path)) + + +def _prepare_media_cache_dir( + media_path: pathlib.Path, legacy_media_path: pathlib.Path +) -> None: + """Prepare the media cache directory.""" + # Migrate media from legacy path to new path. + if legacy_media_path.exists() and not media_path.exists(): + _LOGGER.info( + "Migrating media cache directory from %s to %s", + legacy_media_path, + media_path, + ) + media_path.parent.mkdir(parents=True, exist_ok=True) + try: + shutil.move(legacy_media_path, media_path) + except OSError as error: + _LOGGER.info( + "Failed to migrate media cache directory, abandoning: %s", error + ) + media_path.mkdir(parents=True, exist_ok=True) async def async_get_transcoder(hass: HomeAssistant) -> Transcoder: """Get a nest clip transcoder.""" - media_path = hass.config.path(MEDIA_PATH) + media_path = hass.config.cache_path(DOMAIN, MEDIA_CACHE_PATH) ffmpeg_manager = get_ffmpeg_manager(hass) return Transcoder(ffmpeg_manager.binary, media_path) diff --git a/homeassistant/components/netatmo/binary_sensor.py b/homeassistant/components/netatmo/binary_sensor.py index 81498c3d76707b..c550c31c4a6c90 100644 --- a/homeassistant/components/netatmo/binary_sensor.py +++ b/homeassistant/components/netatmo/binary_sensor.py @@ -1,8 +1,12 @@ """Support for Netatmo binary sensors.""" +from collections.abc import Callable from dataclasses import dataclass +from functools import partial import logging -from typing import Final, cast +from typing import Any, Final, cast + +from pyatmo.modules.device_types import DeviceCategory as NetatmoDeviceCategory from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -13,34 +17,166 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.typing import StateType -from .const import NETATMO_CREATE_WEATHER_BINARY_SENSOR -from .data_handler import NetatmoDevice -from .entity import NetatmoWeatherModuleEntity +from .const import ( + CONF_URL_SECURITY, + DOORTAG_CATEGORY_DOOR, + DOORTAG_CATEGORY_FURNITURE, + DOORTAG_CATEGORY_GARAGE, + DOORTAG_CATEGORY_GATE, + DOORTAG_CATEGORY_OTHER, + DOORTAG_CATEGORY_WINDOW, + DOORTAG_STATUS_CALIBRATING, + DOORTAG_STATUS_CALIBRATION_FAILED, + DOORTAG_STATUS_CLOSED, + DOORTAG_STATUS_MAINTENANCE, + DOORTAG_STATUS_NO_NEWS, + DOORTAG_STATUS_OPEN, + DOORTAG_STATUS_UNDEFINED, + DOORTAG_STATUS_WEAK_SIGNAL, + NETATMO_CREATE_CONNECTIVITY_BINARY_SENSOR, + NETATMO_CREATE_OPENING_BINARY_SENSOR, + NETATMO_CREATE_WEATHER_BINARY_SENSOR, +) +from .data_handler import SIGNAL_NAME, NetatmoDevice +from .entity import NetatmoModuleEntity, NetatmoWeatherModuleEntity _LOGGER = logging.getLogger(__name__) +DEFAULT_OPENING_SENSOR_KEY = "opening_sensor" + +OPENING_STATUS_TO_BINARY_SENSOR_STATE: Final[dict[str, bool | None]] = { + DOORTAG_STATUS_NO_NEWS: None, + DOORTAG_STATUS_CALIBRATING: None, + DOORTAG_STATUS_UNDEFINED: None, + DOORTAG_STATUS_CLOSED: False, + DOORTAG_STATUS_OPEN: True, + DOORTAG_STATUS_CALIBRATION_FAILED: None, + DOORTAG_STATUS_MAINTENANCE: None, + DOORTAG_STATUS_WEAK_SIGNAL: None, +} + + +OPENING_CATEGORY_TO_DEVICE_CLASS: Final[dict[str | None, BinarySensorDeviceClass]] = { + DOORTAG_CATEGORY_DOOR: BinarySensorDeviceClass.DOOR, + DOORTAG_CATEGORY_FURNITURE: BinarySensorDeviceClass.OPENING, + DOORTAG_CATEGORY_GARAGE: BinarySensorDeviceClass.GARAGE_DOOR, + DOORTAG_CATEGORY_GATE: BinarySensorDeviceClass.OPENING, + DOORTAG_CATEGORY_OTHER: BinarySensorDeviceClass.OPENING, + DOORTAG_CATEGORY_WINDOW: BinarySensorDeviceClass.WINDOW, +} + + +def get_opening_category(netatmo_device: NetatmoDevice) -> str: + """Helper function to get opening category from Netatmo API raw data.""" + + # Iterate through each home in the raw data. + for home in netatmo_device.data_handler.account.raw_data["homes"]: + # Check if the modules list exists for the current home. + if "modules" in home: + # Iterate through each module to find a matching ID. + for module in home["modules"]: + if module["id"] == netatmo_device.device.entity_id: + # We found the matching device. Get its category. + if module.get("category") is not None: + return cast(str, module["category"]) + raise ValueError( + f"Device {netatmo_device.device.entity_id} found, " + "but 'category' is missing in raw data." + ) + + raise ValueError( + f"Device {netatmo_device.device.entity_id} not found in Netatmo raw data." + ) + + +OPENING_CATEGORY_TO_KEY: Final[dict[str, str | None]] = { + DOORTAG_CATEGORY_DOOR: None, + DOORTAG_CATEGORY_FURNITURE: DOORTAG_CATEGORY_FURNITURE, + DOORTAG_CATEGORY_GARAGE: None, + DOORTAG_CATEGORY_GATE: DOORTAG_CATEGORY_GATE, + DOORTAG_CATEGORY_OTHER: DEFAULT_OPENING_SENSOR_KEY, + DOORTAG_CATEGORY_WINDOW: None, +} + @dataclass(frozen=True, kw_only=True) class NetatmoBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes Netatmo binary sensor entity.""" - name: str | None = None # The default name of the sensor - netatmo_name: str # The name used by Netatmo API for this sensor + netatmo_name: str | None = ( + None # The name used by Netatmo API for this sensor (exposed feature as attribute) if different than key + ) + value_fn: Callable[[str], str | bool | None] = lambda x: x -NETATMO_WEATHER_BINARY_SENSOR_DESCRIPTIONS: Final[ +NETATMO_CONNECTIVITY_BINARY_SENSOR_DESCRIPTIONS: Final[ list[NetatmoBinarySensorEntityDescription] ] = [ NetatmoBinarySensorEntityDescription( key="reachable", - name="Connectivity", - netatmo_name="reachable", device_class=BinarySensorDeviceClass.CONNECTIVITY, ), ] +# Assuming a Module object with the following attributes: +# {'battery_level': 5780, +# 'battery_percent': None, +# 'battery_state': 'full', +# 'bridge': 'XX:XX:XX:XX:XX:XX', +# 'device_category': , +# 'device_type': , +# 'entity_id': 'NN:NN:NN:NN:NN:NN', +# 'features': {'status', 'battery', 'rf_strength', 'reachable'}, +# 'firmware_name': None, +# 'firmware_revision': 58, +# 'history_features': set(), +# 'history_features_values': {}, +# 'home': , +# 'modules': None, +# 'name': 'YYYYYY', +# 'reachable': True, +# 'rf_strength': 74, +# 'room_id': 'ZZZZZZZZ', +# 'status': 'open'} + +NETATMO_OPENING_BINARY_SENSOR_DESCRIPTIONS: Final[ + list[NetatmoBinarySensorEntityDescription] +] = [ + NetatmoBinarySensorEntityDescription( + key="opening", + netatmo_name="status", + value_fn=OPENING_STATUS_TO_BINARY_SENSOR_STATE.get, + ), +] + +DEVICE_CATEGORY_BINARY_URLS: Final[dict[NetatmoDeviceCategory, str]] = { + NetatmoDeviceCategory.opening: CONF_URL_SECURITY, +} + +DEVICE_CATEGORY_WEATHER_BINARY_SENSORS: Final[ + dict[NetatmoDeviceCategory, list[NetatmoBinarySensorEntityDescription]] +] = { + NetatmoDeviceCategory.air_care: NETATMO_CONNECTIVITY_BINARY_SENSOR_DESCRIPTIONS, + NetatmoDeviceCategory.weather: NETATMO_CONNECTIVITY_BINARY_SENSOR_DESCRIPTIONS, +} + +DEVICE_CATEGORY_CONNECTIVITY_BINARY_SENSORS: Final[ + dict[NetatmoDeviceCategory, list[NetatmoBinarySensorEntityDescription]] +] = { + NetatmoDeviceCategory.opening: NETATMO_CONNECTIVITY_BINARY_SENSOR_DESCRIPTIONS, +} + +DEVICE_CATEGORY_OPENING_BINARY_SENSORS: Final[ + dict[NetatmoDeviceCategory, list[NetatmoBinarySensorEntityDescription]] +] = { + NetatmoDeviceCategory.opening: NETATMO_OPENING_BINARY_SENSOR_DESCRIPTIONS, +} + +DEVICE_CATEGORY_BINARY_PUBLISHERS: Final[list[NetatmoDeviceCategory]] = [ + NetatmoDeviceCategory.opening, +] + async def async_setup_entry( hass: HomeAssistant, @@ -50,25 +186,47 @@ async def async_setup_entry( """Set up Netatmo weather binary sensors based on a config entry.""" @callback - def _create_weather_binary_sensor_entity(netatmo_device: NetatmoDevice) -> None: - """Create weather binary sensor entities for a Netatmo weather device.""" + def _create_binary_sensor_entity( + binarySensorClass: type[ + NetatmoWeatherBinarySensor + | NetatmoOpeningBinarySensor + | NetatmoConnectivityBinarySensor + ], + descriptions: dict[ + NetatmoDeviceCategory, list[NetatmoBinarySensorEntityDescription] + ], + netatmo_device: NetatmoDevice, + ) -> None: + """Create binary sensor entities for a Netatmo device.""" - descriptions_to_add = NETATMO_WEATHER_BINARY_SENSOR_DESCRIPTIONS + if netatmo_device.device.device_category is None: + return - entities: list[NetatmoWeatherBinarySensor] = [] + descriptions_to_add = descriptions.get( + netatmo_device.device.device_category, [] + ) + + entities: list[ + NetatmoWeatherBinarySensor + | NetatmoOpeningBinarySensor + | NetatmoConnectivityBinarySensor + ] = [] # Create binary sensors for module for description in descriptions_to_add: - # Actual check is simple for reachable - feature_check = description.key + 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 "%s" weather binary sensor for device %s', + 'Adding "%s" (native: "%s") binary sensor for device %s', + description.key, feature_check, netatmo_device.device.name, ) entities.append( - NetatmoWeatherBinarySensor( + binarySensorClass( netatmo_device, description, ) @@ -81,35 +239,89 @@ def _create_weather_binary_sensor_entity(netatmo_device: NetatmoDevice) -> None: async_dispatcher_connect( hass, NETATMO_CREATE_WEATHER_BINARY_SENSOR, - _create_weather_binary_sensor_entity, + partial( + _create_binary_sensor_entity, + NetatmoWeatherBinarySensor, + DEVICE_CATEGORY_WEATHER_BINARY_SENSORS, + ), ) ) + entry.async_on_unload( + async_dispatcher_connect( + hass, + NETATMO_CREATE_OPENING_BINARY_SENSOR, + partial( + _create_binary_sensor_entity, + NetatmoOpeningBinarySensor, + DEVICE_CATEGORY_OPENING_BINARY_SENSORS, + ), + ) + ) -class NetatmoWeatherBinarySensor(NetatmoWeatherModuleEntity, BinarySensorEntity): - """Implementation of a Netatmo weather binary sensor.""" + entry.async_on_unload( + async_dispatcher_connect( + hass, + NETATMO_CREATE_CONNECTIVITY_BINARY_SENSOR, + partial( + _create_binary_sensor_entity, + NetatmoConnectivityBinarySensor, + DEVICE_CATEGORY_CONNECTIVITY_BINARY_SENSORS, + ), + ) + ) + + +class NetatmoBinarySensor(NetatmoModuleEntity, BinarySensorEntity): + """Implementation of a Netatmo binary sensor.""" entity_description: NetatmoBinarySensorEntityDescription + _attr_has_entity_name = True def __init__( self, netatmo_device: NetatmoDevice, description: NetatmoBinarySensorEntityDescription, + **kwargs: Any, # Add this to capture extra args from super() ) -> None: - """Initialize a Netatmo weather binary sensor.""" + """Initialize a Netatmo binary 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_BINARY_URLS.get(netatmo_device.device.device_category) + is not None + ): + self._attr_configuration_url = DEVICE_CATEGORY_BINARY_URLS[ + netatmo_device.device.device_category + ] - super().__init__(netatmo_device) + super().__init__(netatmo_device, **kwargs) self.entity_description = description self._attr_unique_id = f"{self.device.entity_id}-{description.key}" + # Register publishers for the entity if needed (not already done in parent class - weather and air_care) + # We need to keep this here because we have two classes depending on it and we want to avoid adding publishers for all binary sensors + if self.device.device_category in DEVICE_CATEGORY_BINARY_PUBLISHERS: + self._publishers.extend( + [ + { + "name": self.home.entity_id, + "home_id": self.home.entity_id, + SIGNAL_NAME: netatmo_device.signal_name, + }, + ] + ) + @callback def async_update_callback(self) -> None: """Update the entity's state.""" - value: StateType | None = None + # Should be the connectivity (reachable) sensor only here as we have update for opening in its class - value = getattr(self.device, self.entity_description.netatmo_name, None) + # Setting reachable sensor, so we just get it directly (backward compatibility to weather binary sensor) + value = getattr(self.device, self.entity_description.key, None) if value is None: self._attr_available = False @@ -117,5 +329,83 @@ def async_update_callback(self) -> None: else: self._attr_available = True self._attr_is_on = cast(bool, value) + self.async_write_ha_state() + + +class NetatmoWeatherBinarySensor(NetatmoWeatherModuleEntity, NetatmoBinarySensor): + """Implementation of a Netatmo weather binary sensor.""" + + entity_description: NetatmoBinarySensorEntityDescription + + def __init__( + self, + netatmo_device: NetatmoDevice, + description: NetatmoBinarySensorEntityDescription, + ) -> None: + """Initialize a Netatmo weather binary sensor.""" + + super().__init__(netatmo_device, description=description) + + +class NetatmoOpeningBinarySensor(NetatmoBinarySensor): + """Implementation of a Netatmo opening binary sensor.""" + + entity_description: NetatmoBinarySensorEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + netatmo_device: NetatmoDevice, + description: NetatmoBinarySensorEntityDescription, + ) -> None: + """Initialize a Netatmo binary sensor.""" + + super().__init__(netatmo_device, description) + + # Apply Dynamic Device Class override + self._attr_device_class = OPENING_CATEGORY_TO_DEVICE_CLASS.get( + get_opening_category(netatmo_device), BinarySensorDeviceClass.OPENING + ) + + # Apply Dynamic Translation Key override if needed + translation_key = OPENING_CATEGORY_TO_KEY.get( + get_opening_category(netatmo_device), DEFAULT_OPENING_SENSOR_KEY + ) + if translation_key is not None: + self._attr_translation_key = translation_key + + @callback + def async_update_callback(self) -> None: + """Update the entity's state.""" + + if not self.device.reachable: + # If reachable is None or False we set availability to False + self._attr_available = False + self._attr_is_on = None + + else: + # If reachable is True, we get the actual value + 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 raw_value is not None: + value = self.entity_description.value_fn(raw_value) + else: + value = None + + # Set sensor state + self._attr_available = True + self._attr_is_on = cast(bool, value) if value is not None else None self.async_write_ha_state() + + +class NetatmoConnectivityBinarySensor(NetatmoBinarySensor): + """Implementation of a Netatmo connectivity binary sensor.""" + + entity_description: NetatmoBinarySensorEntityDescription + _attr_has_entity_name = True diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py index 02d9c2fa3a6807..b33d4898832985 100644 --- a/homeassistant/components/netatmo/config_flow.py +++ b/homeassistant/components/netatmo/config_flow.py @@ -218,7 +218,7 @@ def fix_coordinates(user_input: dict) -> dict: # Ensure coordinates have acceptable length for the Netatmo API for coordinate in (CONF_LAT_NE, CONF_LAT_SW, CONF_LON_NE, CONF_LON_SW): if len(str(user_input[coordinate]).split(".")[1]) < 7: - user_input[coordinate] = user_input[coordinate] + 0.0000001 + user_input[coordinate] = user_input[coordinate] + 1e-7 # Swap coordinates if entered in wrong order if user_input[CONF_LAT_NE] < user_input[CONF_LAT_SW]: diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index e789885f56b437..9a95cd36fed3e8 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -46,9 +46,11 @@ NETATMO_CREATE_CAMERA_LIGHT = "netatmo_create_camera_light" NETATMO_CREATE_CLIMATE = "netatmo_create_climate" 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_LIGHT = "netatmo_create_light" +NETATMO_CREATE_OPENING_BINARY_SENSOR = "netatmo_create_opening_binary_sensor" NETATMO_CREATE_ROOM_SENSOR = "netatmo_create_room_sensor" NETATMO_CREATE_SELECT = "netatmo_create_select" NETATMO_CREATE_SENSOR = "netatmo_create_sensor" @@ -191,6 +193,23 @@ MODE_LIGHT_ON = "on" CAMERA_LIGHT_MODES = [MODE_LIGHT_ON, MODE_LIGHT_OFF, MODE_LIGHT_AUTO] +# Door tag categories +DOORTAG_CATEGORY_DOOR = "door" +DOORTAG_CATEGORY_FURNITURE = "furniture" +DOORTAG_CATEGORY_GARAGE = "garage" +DOORTAG_CATEGORY_GATE = "gate" +DOORTAG_CATEGORY_OTHER = "other" +DOORTAG_CATEGORY_WINDOW = "window" +# Door tag statuses +DOORTAG_STATUS_CALIBRATING = "calibrating" +DOORTAG_STATUS_CALIBRATION_FAILED = "calibration_failed" +DOORTAG_STATUS_CLOSED = "closed" +DOORTAG_STATUS_MAINTENANCE = "maintenance" +DOORTAG_STATUS_NO_NEWS = "no_news" +DOORTAG_STATUS_OPEN = "open" +DOORTAG_STATUS_UNDEFINED = "undefined" +DOORTAG_STATUS_WEAK_SIGNAL = "weak_signal" + # Webhook push_types MUST follow exactly Netatmo's naming on products! # See https://dev.netatmo.com/apidocumentation # e.g. cameras: NACamera, NOC, etc. diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index ee1b369c58c987..31845e1c0c7c42 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -38,9 +38,11 @@ NETATMO_CREATE_CAMERA, NETATMO_CREATE_CAMERA_LIGHT, NETATMO_CREATE_CLIMATE, + NETATMO_CREATE_CONNECTIVITY_BINARY_SENSOR, NETATMO_CREATE_COVER, NETATMO_CREATE_FAN, NETATMO_CREATE_LIGHT, + NETATMO_CREATE_OPENING_BINARY_SENSOR, NETATMO_CREATE_ROOM_SENSOR, NETATMO_CREATE_SELECT, NETATMO_CREATE_SENSOR, @@ -367,6 +369,10 @@ def setup_modules(self, home: pyatmo.Home, signal_home: str) -> None: ], NetatmoDeviceCategory.meter: [NETATMO_CREATE_SENSOR], NetatmoDeviceCategory.fan: [NETATMO_CREATE_FAN], + NetatmoDeviceCategory.opening: [ + NETATMO_CREATE_CONNECTIVITY_BINARY_SENSOR, + NETATMO_CREATE_OPENING_BINARY_SENSOR, + ], } for module in home.modules.values(): if not module.device_category: diff --git a/homeassistant/components/netatmo/entity.py b/homeassistant/components/netatmo/entity.py index b519c75ae554ac..2d12631a3db0f9 100644 --- a/homeassistant/components/netatmo/entity.py +++ b/homeassistant/components/netatmo/entity.py @@ -93,9 +93,11 @@ def async_update_callback(self) -> None: class NetatmoDeviceEntity(NetatmoBaseEntity): """Netatmo entity base class.""" - def __init__(self, data_handler: NetatmoDataHandler, device: NetatmoBase) -> None: + def __init__( + self, data_handler: NetatmoDataHandler, device: NetatmoBase, **kwargs: Any + ) -> None: """Set up Netatmo entity base.""" - super().__init__(data_handler) + super().__init__(data_handler, **kwargs) self.device = device @property @@ -153,9 +155,9 @@ class NetatmoModuleEntity(NetatmoDeviceEntity): device: Module _attr_configuration_url: str - def __init__(self, device: NetatmoDevice) -> None: + def __init__(self, device: NetatmoDevice, **kwargs: Any) -> None: """Set up a Netatmo module entity.""" - super().__init__(device.data_handler, device.device) + super().__init__(device.data_handler, device.device, **kwargs) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device.device.entity_id)}, name=device.device.name, @@ -175,9 +177,9 @@ class NetatmoWeatherModuleEntity(NetatmoModuleEntity): _attr_configuration_url = CONF_URL_WEATHER - def __init__(self, device: NetatmoDevice) -> None: + def __init__(self, device: NetatmoDevice, **kwargs: Any) -> None: """Set up a Netatmo weather module entity.""" - super().__init__(device) + super().__init__(device, **kwargs) assert self.device.device_category category = self.device.device_category.name self._publishers.extend( diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index 3307dcb6d4592e..0aadcbfea13f28 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -54,6 +54,17 @@ } }, "entity": { + "binary_sensor": { + "furniture": { + "name": "Furniture" + }, + "gate": { + "name": "Gate" + }, + "opening_sensor": { + "name": "Opening" + } + }, "button": { "preferred_position": { "name": "Preferred position" diff --git a/homeassistant/components/netdata/sensor.py b/homeassistant/components/netdata/sensor.py index 4346cbe868950a..41adcd2095e243 100644 --- a/homeassistant/components/netdata/sensor.py +++ b/homeassistant/components/netdata/sensor.py @@ -113,35 +113,15 @@ class NetdataSensor(SensorEntity): def __init__(self, netdata, name, sensor, sensor_name, element, icon, unit, invert): """Initialize the Netdata sensor.""" self.netdata = netdata - self._state = None self._sensor = sensor self._element = element - self._sensor_name = self._sensor if sensor_name is None else sensor_name - self._name = name - self._icon = icon - self._unit_of_measurement = unit + if sensor_name is None: + sensor_name = self._sensor + self._attr_name = f"{name} {sensor_name}" + self._attr_icon = icon + self._attr_native_unit_of_measurement = unit self._invert = invert - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name} {self._sensor_name}" - - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit_of_measurement - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return self._icon - - @property - def native_value(self): - """Return the state of the resources.""" - return self._state - @property def available(self) -> bool: """Could the resource be accessed during the last update call.""" @@ -151,9 +131,9 @@ async def async_update(self) -> None: """Get the latest data from Netdata REST API.""" await self.netdata.async_update() resource_data = self.netdata.api.metrics.get(self._sensor) - self._state = round(resource_data["dimensions"][self._element]["value"], 2) * ( - -1 if self._invert else 1 - ) + self._attr_native_value = round( + resource_data["dimensions"][self._element]["value"], 2 + ) * (-1 if self._invert else 1) class NetdataAlarms(SensorEntity): @@ -162,29 +142,18 @@ class NetdataAlarms(SensorEntity): def __init__(self, netdata, name, host, port): """Initialize the Netdata alarm sensor.""" self.netdata = netdata - self._state = None - self._name = name + self._attr_name = f"{name} Alarms" self._host = host self._port = port @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name} Alarms" - - @property - def native_value(self): - """Return the state of the resources.""" - return self._state - - @property - def icon(self): + def icon(self) -> str: """Status symbol if type is symbol.""" - if self._state == "ok": + if self._attr_native_value == "ok": return "mdi:check" - if self._state == "warning": + if self._attr_native_value == "warning": return "mdi:alert-outline" - if self._state == "critical": + if self._attr_native_value == "critical": return "mdi:alert" return "mdi:crosshairs-question" @@ -197,7 +166,7 @@ async def async_update(self) -> None: """Get the latest alarms from Netdata REST API.""" await self.netdata.async_update() alarms = self.netdata.api.alarms["alarms"] - self._state = None + self._attr_native_value = None number_of_alarms = len(alarms) number_of_relevant_alarms = number_of_alarms @@ -211,9 +180,9 @@ async def async_update(self) -> None: ): number_of_relevant_alarms = number_of_relevant_alarms - 1 elif alarms[alarm]["status"] == "CRITICAL": - self._state = "critical" + self._attr_native_value = "critical" return - self._state = "ok" if number_of_relevant_alarms == 0 else "warning" + self._attr_native_value = "ok" if number_of_relevant_alarms == 0 else "warning" class NetdataData: diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index 9aafa482faf965..cbde5ccccadc98 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -2,39 +2,31 @@ from __future__ import annotations -from datetime import timedelta import logging -from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT, CONF_SSL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -from .const import ( - DOMAIN, - KEY_COORDINATOR, - KEY_COORDINATOR_FIRMWARE, - KEY_COORDINATOR_LINK, - KEY_COORDINATOR_SPEED, - KEY_COORDINATOR_TRAFFIC, - KEY_COORDINATOR_UTIL, - KEY_ROUTER, - PLATFORMS, + +from .const import PLATFORMS +from .coordinator import ( + NetgearConfigEntry, + NetgearFirmwareCoordinator, + NetgearLinkCoordinator, + NetgearRuntimeData, + NetgearSpeedTestCoordinator, + NetgearTrackerCoordinator, + NetgearTrafficMeterCoordinator, + NetgearUtilizationCoordinator, ) from .errors import CannotLoginException from .router import NetgearRouter _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=30) -SPEED_TEST_INTERVAL = timedelta(hours=2) -SCAN_INTERVAL_FIRMWARE = timedelta(hours=5) - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: NetgearConfigEntry) -> bool: """Set up Netgear component.""" router = NetgearRouter(hass, entry) try: @@ -59,116 +51,41 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: router.ssl, ) - hass.data.setdefault(DOMAIN, {}) - - async def async_update_devices() -> bool: - """Fetch data from the router.""" - if router.track_devices: - return await router.async_update_device_trackers() - return False - - async def async_update_traffic_meter() -> dict[str, Any] | None: - """Fetch data from the router.""" - return await router.async_get_traffic_meter() - - async def async_update_speed_test() -> dict[str, Any] | None: - """Fetch data from the router.""" - return await router.async_get_speed_test() - - async def async_check_firmware() -> dict[str, Any] | None: - """Check for new firmware of the router.""" - return await router.async_check_new_firmware() - - async def async_update_utilization() -> dict[str, Any] | None: - """Fetch data from the router.""" - return await router.async_get_utilization() - - async def async_check_link_status() -> dict[str, Any] | None: - """Fetch data from the router.""" - return await router.async_get_link_status() - # Create update coordinators - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name=f"{router.device_name} Devices", - update_method=async_update_devices, - update_interval=SCAN_INTERVAL, - ) - coordinator_traffic_meter = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name=f"{router.device_name} Traffic meter", - update_method=async_update_traffic_meter, - update_interval=SCAN_INTERVAL, - ) - coordinator_speed_test = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name=f"{router.device_name} Speed test", - update_method=async_update_speed_test, - update_interval=SPEED_TEST_INTERVAL, - ) - coordinator_firmware = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name=f"{router.device_name} Firmware", - update_method=async_check_firmware, - update_interval=SCAN_INTERVAL_FIRMWARE, - ) - coordinator_utilization = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name=f"{router.device_name} Utilization", - update_method=async_update_utilization, - update_interval=SCAN_INTERVAL, - ) - coordinator_link = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name=f"{router.device_name} Ethernet Link Status", - update_method=async_check_link_status, - update_interval=SCAN_INTERVAL, - ) + coordinator_tracker = NetgearTrackerCoordinator(hass, router, entry) + coordinator_traffic_meter = NetgearTrafficMeterCoordinator(hass, router, entry) + coordinator_speed_test = NetgearSpeedTestCoordinator(hass, router, entry) + coordinator_firmware = NetgearFirmwareCoordinator(hass, router, entry) + coordinator_utilization = NetgearUtilizationCoordinator(hass, router, entry) + coordinator_link = NetgearLinkCoordinator(hass, router, entry) if router.track_devices: - await coordinator.async_config_entry_first_refresh() + await coordinator_tracker.async_config_entry_first_refresh() await coordinator_traffic_meter.async_config_entry_first_refresh() await coordinator_firmware.async_config_entry_first_refresh() await coordinator_utilization.async_config_entry_first_refresh() await coordinator_link.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = { - KEY_ROUTER: router, - KEY_COORDINATOR: coordinator, - KEY_COORDINATOR_TRAFFIC: coordinator_traffic_meter, - KEY_COORDINATOR_SPEED: coordinator_speed_test, - KEY_COORDINATOR_FIRMWARE: coordinator_firmware, - KEY_COORDINATOR_UTIL: coordinator_utilization, - KEY_COORDINATOR_LINK: coordinator_link, - } + entry.runtime_data = NetgearRuntimeData( + router=router, + coordinator_tracker=coordinator_tracker, + coordinator_traffic=coordinator_traffic_meter, + coordinator_speed=coordinator_speed_test, + coordinator_firmware=coordinator_firmware, + coordinator_utilization=coordinator_utilization, + coordinator_link=coordinator_link, + ) 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: NetgearConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - router = hass.data[DOMAIN][entry.entry_id][KEY_ROUTER] - - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - if not hass.data[DOMAIN]: - hass.data.pop(DOMAIN) + router = entry.runtime_data.router if not router.track_devices: router_id = None @@ -193,10 +110,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, config_entry: NetgearConfigEntry, device_entry: dr.DeviceEntry ) -> bool: """Remove a device from a config entry.""" - router = hass.data[DOMAIN][config_entry.entry_id][KEY_ROUTER] + router = config_entry.runtime_data.router device_mac = None for connection in device_entry.connections: diff --git a/homeassistant/components/netgear/button.py b/homeassistant/components/netgear/button.py index 726c1b2296d077..5a89b64594fafd 100644 --- a/homeassistant/components/netgear/button.py +++ b/homeassistant/components/netgear/button.py @@ -9,13 +9,11 @@ ButtonEntity, ButtonEntityDescription, ) -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.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN, KEY_COORDINATOR, KEY_ROUTER +from .coordinator import NetgearConfigEntry, NetgearTrackerCoordinator from .entity import NetgearRouterCoordinatorEntity from .router import NetgearRouter @@ -39,14 +37,13 @@ class NetgearButtonEntityDescription(ButtonEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetgearConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up button for Netgear component.""" - router = hass.data[DOMAIN][entry.entry_id][KEY_ROUTER] - coordinator = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR] + coordinator_tracker = entry.runtime_data.coordinator_tracker async_add_entities( - NetgearRouterButtonEntity(coordinator, router, entity_description) + NetgearRouterButtonEntity(coordinator_tracker, entity_description) for entity_description in BUTTONS ) @@ -58,14 +55,15 @@ class NetgearRouterButtonEntity(NetgearRouterCoordinatorEntity, ButtonEntity): def __init__( self, - coordinator: DataUpdateCoordinator, - router: NetgearRouter, + coordinator: NetgearTrackerCoordinator, entity_description: NetgearButtonEntityDescription, ) -> None: """Initialize a Netgear device.""" - super().__init__(coordinator, router) + super().__init__(coordinator) self.entity_description = entity_description - self._attr_unique_id = f"{router.serial_number}-{entity_description.key}" + self._attr_unique_id = ( + f"{coordinator.router.serial_number}-{entity_description.key}" + ) async def async_press(self) -> None: """Triggers the button press service.""" diff --git a/homeassistant/components/netgear/const.py b/homeassistant/components/netgear/const.py index c8ecd8e7e1d0df..6221de06693ece 100644 --- a/homeassistant/components/netgear/const.py +++ b/homeassistant/components/netgear/const.py @@ -16,14 +16,6 @@ CONF_CONSIDER_HOME = "consider_home" -KEY_ROUTER = "router" -KEY_COORDINATOR = "coordinator" -KEY_COORDINATOR_TRAFFIC = "coordinator_traffic" -KEY_COORDINATOR_SPEED = "coordinator_speed" -KEY_COORDINATOR_FIRMWARE = "coordinator_firmware" -KEY_COORDINATOR_UTIL = "coordinator_utilization" -KEY_COORDINATOR_LINK = "coordinator_link" - DEFAULT_CONSIDER_HOME = timedelta(seconds=180) DEFAULT_NAME = "Netgear router" diff --git a/homeassistant/components/netgear/coordinator.py b/homeassistant/components/netgear/coordinator.py new file mode 100644 index 00000000000000..9ee6b7b7342cad --- /dev/null +++ b/homeassistant/components/netgear/coordinator.py @@ -0,0 +1,163 @@ +"""Models for the Netgear integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .router import NetgearRouter + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=30) +SCAN_INTERVAL_FIRMWARE = timedelta(hours=5) +SPEED_TEST_INTERVAL = timedelta(hours=2) + + +@dataclass +class NetgearRuntimeData: + """Runtime data for the Netgear integration.""" + + router: NetgearRouter + coordinator_tracker: NetgearTrackerCoordinator + coordinator_traffic: NetgearTrafficMeterCoordinator + coordinator_speed: NetgearSpeedTestCoordinator + coordinator_firmware: NetgearFirmwareCoordinator + coordinator_utilization: NetgearUtilizationCoordinator + coordinator_link: NetgearLinkCoordinator + + +type NetgearConfigEntry = ConfigEntry[NetgearRuntimeData] + + +class NetgearDataCoordinator[T](DataUpdateCoordinator[T]): + """Base coordinator for Netgear.""" + + config_entry: NetgearConfigEntry + + def __init__( + self, + hass: HomeAssistant, + router: NetgearRouter, + entry: NetgearConfigEntry, + *, + name: str, + update_interval: timedelta, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=f"{router.device_name} {name}", + update_interval=update_interval, + ) + self.router = router + + +class NetgearTrackerCoordinator(NetgearDataCoordinator[bool]): + """Coordinator for Netgear device tracking.""" + + def __init__( + self, hass: HomeAssistant, router: NetgearRouter, entry: NetgearConfigEntry + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, router, entry, name="Devices", update_interval=SCAN_INTERVAL + ) + + async def _async_update_data(self) -> bool: + """Fetch data from the router.""" + if self.router.track_devices: + return await self.router.async_update_device_trackers() + return False + + +class NetgearTrafficMeterCoordinator(NetgearDataCoordinator[dict[str, Any] | None]): + """Coordinator for Netgear traffic meter data.""" + + def __init__( + self, hass: HomeAssistant, router: NetgearRouter, entry: NetgearConfigEntry + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, router, entry, name="Traffic meter", update_interval=SCAN_INTERVAL + ) + + async def _async_update_data(self) -> dict[str, Any] | None: + """Fetch data from the router.""" + return await self.router.async_get_traffic_meter() + + +class NetgearSpeedTestCoordinator(NetgearDataCoordinator[dict[str, Any] | None]): + """Coordinator for Netgear speed test data.""" + + def __init__( + self, hass: HomeAssistant, router: NetgearRouter, entry: NetgearConfigEntry + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, router, entry, name="Speed test", update_interval=SPEED_TEST_INTERVAL + ) + + async def _async_update_data(self) -> dict[str, Any] | None: + """Fetch data from the router.""" + return await self.router.async_get_speed_test() + + +class NetgearFirmwareCoordinator(NetgearDataCoordinator[dict[str, Any] | None]): + """Coordinator for Netgear firmware updates.""" + + def __init__( + self, hass: HomeAssistant, router: NetgearRouter, entry: NetgearConfigEntry + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, router, entry, name="Firmware", update_interval=SCAN_INTERVAL_FIRMWARE + ) + + async def _async_update_data(self) -> dict[str, Any] | None: + """Check for new firmware of the router.""" + return await self.router.async_check_new_firmware() + + +class NetgearUtilizationCoordinator(NetgearDataCoordinator[dict[str, Any] | None]): + """Coordinator for Netgear utilization data.""" + + def __init__( + self, hass: HomeAssistant, router: NetgearRouter, entry: NetgearConfigEntry + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, router, entry, name="Utilization", update_interval=SCAN_INTERVAL + ) + + async def _async_update_data(self) -> dict[str, Any] | None: + """Fetch data from the router.""" + return await self.router.async_get_utilization() + + +class NetgearLinkCoordinator(NetgearDataCoordinator[dict[str, Any] | None]): + """Coordinator for Netgear Ethernet link status.""" + + def __init__( + self, hass: HomeAssistant, router: NetgearRouter, entry: NetgearConfigEntry + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + router, + entry, + name="Ethernet Link Status", + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self) -> dict[str, Any] | None: + """Fetch data from the router.""" + return await self.router.async_get_link_status() diff --git a/homeassistant/components/netgear/device_tracker.py b/homeassistant/components/netgear/device_tracker.py index 56f4ecac14fc2c..24625a8098698f 100644 --- a/homeassistant/components/netgear/device_tracker.py +++ b/homeassistant/components/netgear/device_tracker.py @@ -5,32 +5,30 @@ import logging from homeassistant.components.device_tracker import ScannerEntity -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 DataUpdateCoordinator -from .const import DEVICE_ICONS, DOMAIN, KEY_COORDINATOR, KEY_ROUTER +from .const import DEVICE_ICONS +from .coordinator import NetgearConfigEntry, NetgearTrackerCoordinator from .entity import NetgearDeviceEntity -from .router import NetgearRouter _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetgearConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for Netgear component.""" - router = hass.data[DOMAIN][entry.entry_id][KEY_ROUTER] - coordinator = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR] + router = entry.runtime_data.router + coordinator_tracker = entry.runtime_data.coordinator_tracker tracked = set() @callback def new_device_callback() -> None: """Add new devices if needed.""" - if not coordinator.data: + if not coordinator_tracker.data: return new_entities = [] @@ -39,14 +37,14 @@ def new_device_callback() -> None: if mac in tracked: continue - new_entities.append(NetgearScannerEntity(coordinator, router, device)) + new_entities.append(NetgearScannerEntity(coordinator_tracker, device)) tracked.add(mac) async_add_entities(new_entities) - entry.async_on_unload(coordinator.async_add_listener(new_device_callback)) + entry.async_on_unload(coordinator_tracker.async_add_listener(new_device_callback)) - coordinator.data = True + coordinator_tracker.data = True new_device_callback() @@ -56,10 +54,12 @@ class NetgearScannerEntity(NetgearDeviceEntity, ScannerEntity): _attr_has_entity_name = False def __init__( - self, coordinator: DataUpdateCoordinator, router: NetgearRouter, device: dict + self, + coordinator: NetgearTrackerCoordinator, + device: dict, ) -> None: """Initialize a Netgear device.""" - super().__init__(coordinator, router, device) + super().__init__(coordinator, device) self._hostname = self.get_hostname() self._icon = DEVICE_ICONS.get(device["device_type"], "mdi:help-network") self._attr_name = self._device_name diff --git a/homeassistant/components/netgear/entity.py b/homeassistant/components/netgear/entity.py index 2610b4c7132d4f..3ba7b76262e605 100644 --- a/homeassistant/components/netgear/entity.py +++ b/homeassistant/components/netgear/entity.py @@ -3,32 +3,33 @@ from __future__ import annotations from abc import abstractmethod +from typing import Any from homeassistant.const import CONF_HOST from homeassistant.core import callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import NetgearDataCoordinator, NetgearTrackerCoordinator from .router import NetgearRouter -class NetgearDeviceEntity(CoordinatorEntity): +class NetgearDeviceEntity(CoordinatorEntity[NetgearTrackerCoordinator]): """Base class for a device connected to a Netgear router.""" _attr_has_entity_name = True def __init__( - self, coordinator: DataUpdateCoordinator, router: NetgearRouter, device: dict + self, + coordinator: NetgearTrackerCoordinator, + device: dict, ) -> None: """Initialize a Netgear device.""" super().__init__(coordinator) - self._router = router + self._router = coordinator.router self._device = device self._mac = device["mac"] self._device_name = self.get_device_name() @@ -38,7 +39,7 @@ def __init__( connections={(dr.CONNECTION_NETWORK_MAC, self._mac)}, default_name=self._device_name, default_model=device["device_model"], - via_device=(DOMAIN, router.unique_id), + via_device=(DOMAIN, coordinator.router.unique_id), ) def get_device_name(self): @@ -86,15 +87,15 @@ def __init__(self, router: NetgearRouter) -> None: ) -class NetgearRouterCoordinatorEntity(NetgearRouterEntity, CoordinatorEntity): +class NetgearRouterCoordinatorEntity[T: NetgearDataCoordinator[Any]]( + NetgearRouterEntity, CoordinatorEntity[T] +): """Base class for a Netgear router entity.""" - def __init__( - self, coordinator: DataUpdateCoordinator, router: NetgearRouter - ) -> None: + def __init__(self, coordinator: T) -> None: """Initialize a Netgear device.""" CoordinatorEntity.__init__(self, coordinator) - NetgearRouterEntity.__init__(self, router) + NetgearRouterEntity.__init__(self, coordinator.router) @abstractmethod @callback diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py index 521e18098ebbde..5372ae70bb5bf6 100644 --- a/homeassistant/components/netgear/sensor.py +++ b/homeassistant/components/netgear/sensor.py @@ -7,6 +7,7 @@ from datetime import date, datetime from decimal import Decimal import logging +from typing import Any from homeassistant.components.sensor import ( RestoreSensor, @@ -15,7 +16,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, EntityCategory, @@ -26,19 +26,13 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -from .const import ( - DOMAIN, - KEY_COORDINATOR, - KEY_COORDINATOR_LINK, - KEY_COORDINATOR_SPEED, - KEY_COORDINATOR_TRAFFIC, - KEY_COORDINATOR_UTIL, - KEY_ROUTER, + +from .coordinator import ( + NetgearConfigEntry, + NetgearDataCoordinator, + NetgearTrackerCoordinator, ) from .entity import NetgearDeviceEntity, NetgearRouterCoordinatorEntity -from .router import NetgearRouter _LOGGER = logging.getLogger(__name__) @@ -275,19 +269,19 @@ class NetgearSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetgearConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up device tracker for Netgear component.""" - router = hass.data[DOMAIN][entry.entry_id][KEY_ROUTER] - coordinator = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR] - coordinator_traffic = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR_TRAFFIC] - coordinator_speed = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR_SPEED] - coordinator_utilization = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR_UTIL] - coordinator_link = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR_LINK] + """Set up Netgear sensors from a config entry.""" + router = entry.runtime_data.router + coordinator_tracker = entry.runtime_data.coordinator_tracker + coordinator_traffic = entry.runtime_data.coordinator_traffic + coordinator_speed = entry.runtime_data.coordinator_speed + coordinator_utilization = entry.runtime_data.coordinator_utilization + coordinator_link = entry.runtime_data.coordinator_link async_add_entities( - NetgearRouterSensorEntity(coordinator, router, description) + NetgearRouterSensorEntity(coordinator, description) for (coordinator, descriptions) in ( (coordinator_traffic, SENSOR_TRAFFIC_TYPES), (coordinator_speed, SENSOR_SPEED_TYPES), @@ -306,7 +300,7 @@ async def async_setup_entry( @callback def new_device_callback() -> None: """Add new devices if needed.""" - if not coordinator.data: + if not coordinator_tracker.data: return new_entities: list[NetgearSensorEntity] = [] @@ -316,16 +310,16 @@ def new_device_callback() -> None: continue new_entities.extend( - NetgearSensorEntity(coordinator, router, device, attribute) + NetgearSensorEntity(coordinator_tracker, device, attribute) for attribute in sensors ) tracked.add(mac) async_add_entities(new_entities) - entry.async_on_unload(coordinator.async_add_listener(new_device_callback)) + entry.async_on_unload(coordinator_tracker.async_add_listener(new_device_callback)) - coordinator.data = True + coordinator_tracker.data = True new_device_callback() @@ -334,13 +328,12 @@ class NetgearSensorEntity(NetgearDeviceEntity, SensorEntity): def __init__( self, - coordinator: DataUpdateCoordinator, - router: NetgearRouter, + coordinator: NetgearTrackerCoordinator, device: dict, attribute: str, ) -> None: """Initialize a Netgear device.""" - super().__init__(coordinator, router, device) + super().__init__(coordinator, device) self._attribute = attribute self.entity_description = SENSOR_TYPES[attribute] self._attr_unique_id = f"{self._mac}-{attribute}" @@ -373,14 +366,13 @@ class NetgearRouterSensorEntity(NetgearRouterCoordinatorEntity, RestoreSensor): def __init__( self, - coordinator: DataUpdateCoordinator, - router: NetgearRouter, + coordinator: NetgearDataCoordinator[dict[str, Any] | None], entity_description: NetgearSensorEntityDescription, ) -> None: """Initialize a Netgear device.""" - super().__init__(coordinator, router) + super().__init__(coordinator) self.entity_description = entity_description - self._attr_unique_id = f"{router.serial_number}-{entity_description.key}-{entity_description.index}" + self._attr_unique_id = f"{coordinator.router.serial_number}-{entity_description.key}-{entity_description.index}" self._value: StateType | date | datetime | Decimal = None self.async_update_device() diff --git a/homeassistant/components/netgear/switch.py b/homeassistant/components/netgear/switch.py index 712475b9b34999..1bf245242fb293 100644 --- a/homeassistant/components/netgear/switch.py +++ b/homeassistant/components/netgear/switch.py @@ -9,13 +9,11 @@ from pynetgear import ALLOW, BLOCK 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 homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN, KEY_COORDINATOR, KEY_ROUTER +from .coordinator import NetgearConfigEntry, NetgearTrackerCoordinator from .entity import NetgearDeviceEntity, NetgearRouterEntity from .router import NetgearRouter @@ -100,11 +98,11 @@ class NetgearSwitchEntityDescription(SwitchEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetgearConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches for Netgear component.""" - router = hass.data[DOMAIN][entry.entry_id][KEY_ROUTER] + router = entry.runtime_data.router async_add_entities( NetgearRouterSwitchEntity(router, description) @@ -112,14 +110,14 @@ async def async_setup_entry( ) # Entities per network device - coordinator = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR] + coordinator_tracker = entry.runtime_data.coordinator_tracker tracked = set() @callback def new_device_callback() -> None: """Add new devices if needed.""" new_entities = [] - if not coordinator.data: + if not coordinator_tracker.data: return for mac, device in router.devices.items(): @@ -128,7 +126,7 @@ def new_device_callback() -> None: new_entities.extend( [ - NetgearAllowBlock(coordinator, router, device, entity_description) + NetgearAllowBlock(coordinator_tracker, device, entity_description) for entity_description in SWITCH_TYPES ] ) @@ -136,9 +134,9 @@ def new_device_callback() -> None: async_add_entities(new_entities) - entry.async_on_unload(coordinator.async_add_listener(new_device_callback)) + entry.async_on_unload(coordinator_tracker.async_add_listener(new_device_callback)) - coordinator.data = True + coordinator_tracker.data = True new_device_callback() @@ -149,13 +147,12 @@ class NetgearAllowBlock(NetgearDeviceEntity, SwitchEntity): def __init__( self, - coordinator: DataUpdateCoordinator, - router: NetgearRouter, + coordinator: NetgearTrackerCoordinator, device: dict, entity_description: SwitchEntityDescription, ) -> None: """Initialize a Netgear device.""" - super().__init__(coordinator, router, device) + super().__init__(coordinator, device) self.entity_description = entity_description self._attr_unique_id = f"{self._mac}-{entity_description.key}" self.async_update_device() diff --git a/homeassistant/components/netgear/update.py b/homeassistant/components/netgear/update.py index 388ad8bff4f100..15973348a8e34b 100644 --- a/homeassistant/components/netgear/update.py +++ b/homeassistant/components/netgear/update.py @@ -10,32 +10,30 @@ UpdateEntity, UpdateEntityFeature, ) -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 DataUpdateCoordinator -from .const import DOMAIN, KEY_COORDINATOR_FIRMWARE, KEY_ROUTER +from .coordinator import NetgearConfigEntry, NetgearFirmwareCoordinator from .entity import NetgearRouterCoordinatorEntity -from .router import NetgearRouter LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetgearConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up update entities for Netgear component.""" - router = hass.data[DOMAIN][entry.entry_id][KEY_ROUTER] - coordinator = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR_FIRMWARE] - entities = [NetgearUpdateEntity(coordinator, router)] + coordinator = entry.runtime_data.coordinator_firmware + entities = [NetgearUpdateEntity(coordinator)] async_add_entities(entities) -class NetgearUpdateEntity(NetgearRouterCoordinatorEntity, UpdateEntity): +class NetgearUpdateEntity( + NetgearRouterCoordinatorEntity[NetgearFirmwareCoordinator], UpdateEntity +): """Update entity for a Netgear device.""" _attr_device_class = UpdateDeviceClass.FIRMWARE @@ -43,12 +41,11 @@ class NetgearUpdateEntity(NetgearRouterCoordinatorEntity, UpdateEntity): def __init__( self, - coordinator: DataUpdateCoordinator, - router: NetgearRouter, + coordinator: NetgearFirmwareCoordinator, ) -> None: """Initialize a Netgear device.""" - super().__init__(coordinator, router) - self._attr_unique_id = f"{router.serial_number}-update" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.router.serial_number}-update" @property def installed_version(self) -> str | None: diff --git a/homeassistant/components/netgear_lte/binary_sensor.py b/homeassistant/components/netgear_lte/binary_sensor.py index 890bcb374434e4..881e34d4390403 100644 --- a/homeassistant/components/netgear_lte/binary_sensor.py +++ b/homeassistant/components/netgear_lte/binary_sensor.py @@ -51,6 +51,6 @@ class NetgearLTEBinarySensor(LTEEntity, BinarySensorEntity): """Netgear LTE binary sensor entity.""" @property - def is_on(self): + def is_on(self) -> bool: """Return true if the binary sensor is on.""" return getattr(self.coordinator.data, self.entity_description.key) diff --git a/homeassistant/components/netio/switch.py b/homeassistant/components/netio/switch.py index 4560b7a2ecce64..8ab912c7a97161 100644 --- a/homeassistant/components/netio/switch.py +++ b/homeassistant/components/netio/switch.py @@ -174,7 +174,7 @@ def _set(self, value): self.schedule_update_ha_state() @property - def is_on(self): + def is_on(self) -> bool: """Return the switch's status.""" return self.netio.states[int(self.outlet) - 1] diff --git a/homeassistant/components/nexia/binary_sensor.py b/homeassistant/components/nexia/binary_sensor.py index 224836c81e6bed..735c1f28371319 100644 --- a/homeassistant/components/nexia/binary_sensor.py +++ b/homeassistant/components/nexia/binary_sensor.py @@ -53,6 +53,6 @@ def __init__(self, coordinator, thermostat, sensor_call, translation_key): self._attr_translation_key = translation_key @property - def is_on(self): + def is_on(self) -> bool: """Return the status of the sensor.""" return getattr(self._thermostat, self._call)() diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py index 1e698713935c07..bc36fc35bd8abc 100644 --- a/homeassistant/components/nexia/climate.py +++ b/homeassistant/components/nexia/climate.py @@ -199,12 +199,12 @@ def is_fan_on(self): return self._thermostat.is_blower_active() @property - def current_temperature(self): + def current_temperature(self) -> int: """Return the current temperature.""" return self._zone.get_temperature() @property - def fan_mode(self): + def fan_mode(self) -> str | None: """Return the fan setting.""" return self._thermostat.get_fan_mode() @@ -275,14 +275,14 @@ def target_humidity(self) -> float | None: return None @property - def current_humidity(self): + def current_humidity(self) -> float | None: """Humidity indoors.""" if self._has_relative_humidity: return percent_conv(self._thermostat.get_relative_humidity()) return None @property - def target_temperature(self): + def target_temperature(self) -> int | None: """Temperature we try to reach.""" current_mode = self._zone.get_current_mode() @@ -293,7 +293,7 @@ def target_temperature(self): return None @property - def target_temperature_high(self): + def target_temperature_high(self) -> int | None: """Highest temperature we are trying to reach.""" current_mode = self._zone.get_current_mode() @@ -302,7 +302,7 @@ def target_temperature_high(self): return self._zone.get_cooling_setpoint() @property - def target_temperature_low(self): + def target_temperature_low(self) -> int | None: """Lowest temperature we are trying to reach.""" current_mode = self._zone.get_current_mode() diff --git a/homeassistant/components/nintendo_parental_controls/__init__.py b/homeassistant/components/nintendo_parental_controls/__init__.py index c1aa2458931534..6efe2828718840 100644 --- a/homeassistant/components/nintendo_parental_controls/__init__.py +++ b/homeassistant/components/nintendo_parental_controls/__init__.py @@ -20,11 +20,11 @@ from .services import async_setup_services _PLATFORMS: list[Platform] = [ - Platform.SENSOR, - Platform.TIME, - Platform.SWITCH, Platform.NUMBER, Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + Platform.TIME, ] PLATFORM_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 97759db4c13249..e18fced8f8a8c1 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -161,9 +161,9 @@ async def _async_send_message(self, **kwargs: Any) -> None: Should not be overridden, handle setting last notification timestamp. """ + await self.async_send_message(**kwargs) self.__set_state(dt_util.utcnow().isoformat()) self.async_write_ha_state() - await self.async_send_message(**kwargs) def send_message(self, message: str, title: str | None = None) -> None: """Send a message.""" diff --git a/homeassistant/components/nrgkick/__init__.py b/homeassistant/components/nrgkick/__init__.py index 88912e6c144977..974a6ba0622d11 100644 --- a/homeassistant/components/nrgkick/__init__.py +++ b/homeassistant/components/nrgkick/__init__.py @@ -11,6 +11,9 @@ from .coordinator import NRGkickConfigEntry, NRGkickDataUpdateCoordinator PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.DEVICE_TRACKER, + Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/nrgkick/binary_sensor.py b/homeassistant/components/nrgkick/binary_sensor.py new file mode 100644 index 00000000000000..41794f31730c93 --- /dev/null +++ b/homeassistant/components/nrgkick/binary_sensor.py @@ -0,0 +1,76 @@ +"""Binary sensor platform for NRGkick.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import NRGkickConfigEntry, NRGkickData, NRGkickDataUpdateCoordinator +from .entity import NRGkickEntity, get_nested_dict_value + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class NRGkickBinarySensorEntityDescription(BinarySensorEntityDescription): + """Class describing NRGkick binary sensor entities.""" + + is_on_fn: Callable[[NRGkickData], bool | None] + + +BINARY_SENSORS: tuple[NRGkickBinarySensorEntityDescription, ...] = ( + NRGkickBinarySensorEntityDescription( + key="charge_permitted", + translation_key="charge_permitted", + is_on_fn=lambda data: ( + bool(value) + if ( + value := get_nested_dict_value( + data.values, "general", "charge_permitted" + ) + ) + is not None + else None + ), + ), +) + + +async def async_setup_entry( + _hass: HomeAssistant, + entry: NRGkickConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up NRGkick binary sensors based on a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + NRGkickBinarySensor(coordinator, description) for description in BINARY_SENSORS + ) + + +class NRGkickBinarySensor(NRGkickEntity, BinarySensorEntity): + """Representation of a NRGkick binary sensor.""" + + entity_description: NRGkickBinarySensorEntityDescription + + def __init__( + self, + coordinator: NRGkickDataUpdateCoordinator, + entity_description: NRGkickBinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator, entity_description.key) + self.entity_description = entity_description + + @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/nrgkick/config_flow.py b/homeassistant/components/nrgkick/config_flow.py index 943992cdd46300..b99402ab600f24 100644 --- a/homeassistant/components/nrgkick/config_flow.py +++ b/homeassistant/components/nrgkick/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import TYPE_CHECKING, Any @@ -119,6 +120,56 @@ def __init__(self) -> None: self._discovered_name: str | None = None self._pending_host: str | None = None + async def _async_validate_host( + self, + host: str, + errors: dict[str, str], + ) -> tuple[dict[str, Any] | None, bool]: + """Validate host connection and populate errors dict on failure. + + Returns (info, needs_auth). When needs_auth is True, the caller + should store the host and redirect to the appropriate auth step. + """ + try: + return await validate_input(self.hass, host), False + except NRGkickApiClientApiDisabledError: + errors["base"] = "json_api_disabled" + except NRGkickApiClientAuthenticationError: + return None, True + except NRGkickApiClientInvalidResponseError: + errors["base"] = "invalid_response" + except NRGkickApiClientCommunicationError: + errors["base"] = "cannot_connect" + except NRGkickApiClientError: + _LOGGER.exception("Unexpected error") + errors["base"] = "unknown" + return None, False + + async def _async_validate_credentials( + self, + host: str, + errors: dict[str, str], + username: str | None = None, + password: str | None = None, + ) -> dict[str, Any] | None: + """Validate credentials and populate errors dict on failure.""" + try: + return await validate_input( + self.hass, host, username=username, password=password + ) + except NRGkickApiClientApiDisabledError: + errors["base"] = "json_api_disabled" + except NRGkickApiClientAuthenticationError: + errors["base"] = "invalid_auth" + except NRGkickApiClientInvalidResponseError: + errors["base"] = "invalid_response" + except NRGkickApiClientCommunicationError: + errors["base"] = "cannot_connect" + except NRGkickApiClientError: + _LOGGER.exception("Unexpected error") + errors["base"] = "unknown" + return None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -130,21 +181,11 @@ async def async_step_user( except vol.Invalid: errors["base"] = "cannot_connect" else: - try: - info = await validate_input(self.hass, host) - except NRGkickApiClientApiDisabledError: - errors["base"] = "json_api_disabled" - except NRGkickApiClientAuthenticationError: + info, needs_auth = await self._async_validate_host(host, errors) + if needs_auth: self._pending_host = host return await self.async_step_user_auth() - except NRGkickApiClientInvalidResponseError: - errors["base"] = "invalid_response" - except NRGkickApiClientCommunicationError: - errors["base"] = "cannot_connect" - except NRGkickApiClientError: - _LOGGER.exception("Unexpected error") - errors["base"] = "unknown" - else: + if info: await self.async_set_unique_id( info["serial"], raise_on_progress=False ) @@ -169,36 +210,20 @@ async def async_step_user_auth( assert self._pending_host is not None if user_input is not None: - username = user_input.get(CONF_USERNAME) - password = user_input.get(CONF_PASSWORD) - - try: - info = await validate_input( - self.hass, - self._pending_host, - username=username, - password=password, - ) - except NRGkickApiClientApiDisabledError: - errors["base"] = "json_api_disabled" - except NRGkickApiClientAuthenticationError: - errors["base"] = "invalid_auth" - except NRGkickApiClientInvalidResponseError: - errors["base"] = "invalid_response" - except NRGkickApiClientCommunicationError: - errors["base"] = "cannot_connect" - except NRGkickApiClientError: - _LOGGER.exception("Unexpected error") - errors["base"] = "unknown" - else: + if info := await self._async_validate_credentials( + self._pending_host, + errors, + username=user_input.get(CONF_USERNAME), + password=user_input.get(CONF_PASSWORD), + ): await self.async_set_unique_id(info["serial"], raise_on_progress=False) self._abort_if_unique_id_configured() return self.async_create_entry( title=info["title"], data={ CONF_HOST: self._pending_host, - CONF_USERNAME: username, - CONF_PASSWORD: password, + CONF_USERNAME: user_input.get(CONF_USERNAME), + CONF_PASSWORD: user_input.get(CONF_PASSWORD), }, ) @@ -211,6 +236,119 @@ async def async_step_user_auth( }, ) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle initiation of 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.""" + errors: dict[str, str] = {} + + if user_input is not None: + reauth_entry = self._get_reauth_entry() + if info := await self._async_validate_credentials( + reauth_entry.data[CONF_HOST], + errors, + username=user_input.get(CONF_USERNAME), + password=user_input.get(CONF_PASSWORD), + ): + await self.async_set_unique_id(info["serial"], raise_on_progress=False) + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + reauth_entry, + data_updates=user_input, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + STEP_AUTH_DATA_SCHEMA, + self._get_reauth_entry().data, + ), + 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: + try: + host = _normalize_host(user_input[CONF_HOST]) + except vol.Invalid: + errors["base"] = "cannot_connect" + else: + info, needs_auth = await self._async_validate_host(host, errors) + if needs_auth: + self._pending_host = host + return await self.async_step_reconfigure_auth() + if info: + await self.async_set_unique_id( + info["serial"], raise_on_progress=False + ) + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={CONF_HOST: host}, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, + reconfigure_entry.data, + ), + errors=errors, + ) + + async def async_step_reconfigure_auth( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration authentication step.""" + errors: dict[str, str] = {} + + if TYPE_CHECKING: + assert self._pending_host is not None + + reconfigure_entry = self._get_reconfigure_entry() + if user_input is not None: + username = user_input.get(CONF_USERNAME) + password = user_input.get(CONF_PASSWORD) + if info := await self._async_validate_credentials( + self._pending_host, + errors, + username=username, + password=password, + ): + await self.async_set_unique_id(info["serial"], raise_on_progress=False) + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={ + CONF_HOST: self._pending_host, + CONF_USERNAME: username, + CONF_PASSWORD: password, + }, + ) + + return self.async_show_form( + step_id="reconfigure_auth", + data_schema=self.add_suggested_values_to_schema( + STEP_AUTH_DATA_SCHEMA, + reconfigure_entry.data, + ), + errors=errors, + description_placeholders={ + "device_ip": self._pending_host, + }, + ) + async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: @@ -235,8 +373,9 @@ async def async_step_zeroconf( # Store discovery info for the confirmation step. self._discovered_host = discovery_info.host # Fallback: device_name -> model_type -> "NRGkick". - self._discovered_name = device_name or model_type or "NRGkick" - self.context["title_placeholders"] = {"name": self._discovered_name} + discovered_name = device_name or model_type or "NRGkick" + self._discovered_name = discovered_name + self.context["title_placeholders"] = {"name": discovered_name} # If JSON API is disabled, guide the user through enabling it. if json_api_enabled != "1": @@ -274,21 +413,13 @@ async def async_step_zeroconf_enable_json_api( assert self._discovered_name is not None if user_input is not None: - try: - info = await validate_input(self.hass, self._discovered_host) - except NRGkickApiClientApiDisabledError: - errors["base"] = "json_api_disabled" - except NRGkickApiClientAuthenticationError: + info, needs_auth = await self._async_validate_host( + self._discovered_host, errors + ) + if needs_auth: self._pending_host = self._discovered_host return await self.async_step_user_auth() - except NRGkickApiClientInvalidResponseError: - errors["base"] = "invalid_response" - except NRGkickApiClientCommunicationError: - errors["base"] = "cannot_connect" - except NRGkickApiClientError: - _LOGGER.exception("Unexpected error") - errors["base"] = "unknown" - else: + if info: return self.async_create_entry( title=info["title"], data={CONF_HOST: self._discovered_host} ) diff --git a/homeassistant/components/nrgkick/coordinator.py b/homeassistant/components/nrgkick/coordinator.py index b83079d64fe035..d9cc6c9966980a 100644 --- a/homeassistant/components/nrgkick/coordinator.py +++ b/homeassistant/components/nrgkick/coordinator.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DEFAULT_SCAN_INTERVAL, DOMAIN @@ -65,7 +65,7 @@ async def _async_update_data(self) -> NRGkickData: control = await self.api.get_control() values = await self.api.get_values(raw=True) except NRGkickAuthenticationError as error: - raise ConfigEntryError( + raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="authentication_error", ) from error diff --git a/homeassistant/components/nrgkick/device_tracker.py b/homeassistant/components/nrgkick/device_tracker.py new file mode 100644 index 00000000000000..5e995e5f35ceb5 --- /dev/null +++ b/homeassistant/components/nrgkick/device_tracker.py @@ -0,0 +1,74 @@ +"""Device tracker platform for NRGkick.""" + +from __future__ import annotations + +from typing import Any, Final + +from homeassistant.components.device_tracker import SourceType +from homeassistant.components.device_tracker.config_entry import TrackerEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import NRGkickConfigEntry, NRGkickDataUpdateCoordinator +from .entity import NRGkickEntity, get_nested_dict_value + +PARALLEL_UPDATES = 0 + +TRACKER_KEY: Final = "gps_tracker" + + +async def async_setup_entry( + _hass: HomeAssistant, + entry: NRGkickConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up NRGkick device tracker based on a config entry.""" + coordinator = entry.runtime_data + + data = coordinator.data + assert data is not None + + info_data: dict[str, Any] = data.info + general_info: dict[str, Any] = info_data.get("general", {}) + model_type = general_info.get("model_type") + + # GPS module is only available on SIM-capable models (same check as cellular + # sensors). SIM-capable models include "SIM" in their model type string. + has_sim_module = isinstance(model_type, str) and "SIM" in model_type.upper() + + if has_sim_module: + async_add_entities([NRGkickDeviceTracker(coordinator)]) + + +class NRGkickDeviceTracker(NRGkickEntity, TrackerEntity): + """Representation of a NRGkick GPS device tracker.""" + + _attr_translation_key = TRACKER_KEY + _attr_source_type = SourceType.GPS + + def __init__( + self, + coordinator: NRGkickDataUpdateCoordinator, + ) -> None: + """Initialize the device tracker.""" + super().__init__(coordinator, TRACKER_KEY) + + def _gps_float(self, key: str) -> float | None: + """Return a GPS value as float, or None if GPS data is unavailable.""" + value = get_nested_dict_value(self.coordinator.data.info, "gps", key) + return float(value) if value is not None else None + + @property + def latitude(self) -> float | None: + """Return latitude value of the device.""" + return self._gps_float("latitude") + + @property + def longitude(self) -> float | None: + """Return longitude value of the device.""" + return self._gps_float("longitude") + + @property + def location_accuracy(self) -> float: + """Return the location accuracy of the device.""" + return self._gps_float("accuracy") or 0.0 diff --git a/homeassistant/components/nrgkick/diagnostics.py b/homeassistant/components/nrgkick/diagnostics.py new file mode 100644 index 00000000000000..c9b9716a212e24 --- /dev/null +++ b/homeassistant/components/nrgkick/diagnostics.py @@ -0,0 +1,38 @@ +"""Diagnostics support for NRGkick.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_PASSWORD, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant + +from .coordinator import NRGkickConfigEntry + +TO_REDACT = { + ATTR_LATITUDE, + ATTR_LONGITUDE, + "altitude", + CONF_PASSWORD, + CONF_USERNAME, +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: NRGkickConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return async_redact_data( + { + "entry_data": entry.data, + "coordinator_data": asdict(entry.runtime_data.data), + }, + TO_REDACT, + ) diff --git a/homeassistant/components/nrgkick/entity.py b/homeassistant/components/nrgkick/entity.py index 336d7958d45e41..30b82b4ff785dc 100644 --- a/homeassistant/components/nrgkick/entity.py +++ b/homeassistant/components/nrgkick/entity.py @@ -14,6 +14,17 @@ from .coordinator import NRGkickDataUpdateCoordinator +def get_nested_dict_value(data: Any, *keys: str) -> Any: + """Safely get a nested value from dict-like API responses.""" + current: Any = data + for key in keys: + try: + current = current.get(key) + except AttributeError: + return None + return current + + class NRGkickEntity(CoordinatorEntity[NRGkickDataUpdateCoordinator]): """Base class for NRGkick entities with common device info setup.""" diff --git a/homeassistant/components/nrgkick/icons.json b/homeassistant/components/nrgkick/icons.json index a2465678a81c14..4b04a4de4f6c43 100644 --- a/homeassistant/components/nrgkick/icons.json +++ b/homeassistant/components/nrgkick/icons.json @@ -1,5 +1,26 @@ { "entity": { + "binary_sensor": { + "charge_permitted": { + "default": "mdi:ev-station" + } + }, + "device_tracker": { + "gps_tracker": { + "default": "mdi:map-marker" + } + }, + "number": { + "current_set": { + "default": "mdi:current-ac" + }, + "energy_limit": { + "default": "mdi:battery-charging-100" + }, + "phase_count": { + "default": "mdi:sine-wave" + } + }, "sensor": { "charge_count": { "default": "mdi:counter" diff --git a/homeassistant/components/nrgkick/manifest.json b/homeassistant/components/nrgkick/manifest.json index dabd989d915a64..0516f0eb5ae7b7 100644 --- a/homeassistant/components/nrgkick/manifest.json +++ b/homeassistant/components/nrgkick/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/nrgkick", "integration_type": "device", "iot_class": "local_polling", - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["nrgkick-api==1.7.1"], "zeroconf": ["_nrgkick._tcp.local."] } diff --git a/homeassistant/components/nrgkick/number.py b/homeassistant/components/nrgkick/number.py new file mode 100644 index 00000000000000..3261650b824a9f --- /dev/null +++ b/homeassistant/components/nrgkick/number.py @@ -0,0 +1,155 @@ +"""Number platform for NRGkick.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from nrgkick_api.const import ( + CONTROL_KEY_CURRENT_SET, + CONTROL_KEY_ENERGY_LIMIT, + CONTROL_KEY_PHASE_COUNT, +) + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.const import UnitOfElectricCurrent, UnitOfEnergy +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import NRGkickConfigEntry, NRGkickData, NRGkickDataUpdateCoordinator +from .entity import NRGkickEntity + +PARALLEL_UPDATES = 1 + +MIN_CHARGING_CURRENT = 6 + + +def _get_current_set_max(data: NRGkickData) -> float: + """Return the maximum current setpoint. + + Uses the lower of the device rated current and the connector max current. + The device always has a rated current; the connector may be absent. + """ + rated: float = data.info["general"]["rated_current"] + connector_max = data.info.get("connector", {}).get("max_current") + if connector_max is None: + return rated + return min(rated, float(connector_max)) + + +def _get_phase_count_max(data: NRGkickData) -> float: + """Return the maximum phase count based on the attached connector.""" + connector_phases = data.info.get("connector", {}).get("phase_count") + if connector_phases is None: + return 3.0 + return float(connector_phases) + + +@dataclass(frozen=True, kw_only=True) +class NRGkickNumberEntityDescription(NumberEntityDescription): + """Class describing NRGkick number entities.""" + + value_fn: Callable[[NRGkickData], float | None] + set_value_fn: Callable[[NRGkickDataUpdateCoordinator, float], Awaitable[Any]] + max_value_fn: Callable[[NRGkickData], float] | None = None + + +NUMBERS: tuple[NRGkickNumberEntityDescription, ...] = ( + NRGkickNumberEntityDescription( + key="current_set", + translation_key="current_set", + device_class=NumberDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + native_min_value=MIN_CHARGING_CURRENT, + native_step=0.1, + mode=NumberMode.SLIDER, + value_fn=lambda data: data.control.get(CONTROL_KEY_CURRENT_SET), + set_value_fn=lambda coordinator, value: coordinator.api.set_current(value), + max_value_fn=_get_current_set_max, + ), + NRGkickNumberEntityDescription( + key="energy_limit", + translation_key="energy_limit", + device_class=NumberDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + native_min_value=0, + native_max_value=100000, + native_step=1, + mode=NumberMode.BOX, + value_fn=lambda data: data.control.get(CONTROL_KEY_ENERGY_LIMIT), + set_value_fn=lambda coordinator, value: coordinator.api.set_energy_limit( + int(value) + ), + ), + NRGkickNumberEntityDescription( + key="phase_count", + translation_key="phase_count", + native_min_value=1, + native_max_value=3, + native_step=1, + mode=NumberMode.SLIDER, + value_fn=lambda data: data.control.get(CONTROL_KEY_PHASE_COUNT), + set_value_fn=lambda coordinator, value: coordinator.api.set_phase_count( + int(value) + ), + max_value_fn=_get_phase_count_max, + ), +) + + +async def async_setup_entry( + _hass: HomeAssistant, + entry: NRGkickConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up NRGkick number entities based on a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + NRGkickNumber(coordinator, description) for description in NUMBERS + ) + + +class NRGkickNumber(NRGkickEntity, NumberEntity): + """Representation of an NRGkick number entity.""" + + entity_description: NRGkickNumberEntityDescription + + def __init__( + self, + coordinator: NRGkickDataUpdateCoordinator, + description: NRGkickNumberEntityDescription, + ) -> None: + """Initialize the number entity.""" + self.entity_description = description + super().__init__(coordinator, description.key) + + @property + def native_max_value(self) -> float: + """Return the maximum value.""" + if self.entity_description.max_value_fn is not None: + data = self.coordinator.data + if TYPE_CHECKING: + assert data is not None + return self.entity_description.max_value_fn(data) + return super().native_max_value + + @property + def native_value(self) -> float | None: + """Return the current value.""" + data = self.coordinator.data + if TYPE_CHECKING: + assert data is not None + return self.entity_description.value_fn(data) + + async def async_set_native_value(self, value: float) -> None: + """Set the value.""" + await self._async_call_api( + self.entity_description.set_value_fn(self.coordinator, value) + ) diff --git a/homeassistant/components/nrgkick/quality_scale.yaml b/homeassistant/components/nrgkick/quality_scale.yaml index 1d832b931ec9b5..7bdc82b665babd 100644 --- a/homeassistant/components/nrgkick/quality_scale.yaml +++ b/homeassistant/components/nrgkick/quality_scale.yaml @@ -41,14 +41,14 @@ rules: docs-installation-parameters: done entity-unavailable: done integration-owner: done - log-when-unavailable: todo + log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold devices: done - diagnostics: todo + diagnostics: done discovery: done discovery-update-info: done docs-data-update: done @@ -68,7 +68,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: todo stale-devices: status: exempt diff --git a/homeassistant/components/nrgkick/sensor.py b/homeassistant/components/nrgkick/sensor.py index 090a1f19c3f0b0..cfbd9a9ec9dc0b 100644 --- a/homeassistant/components/nrgkick/sensor.py +++ b/homeassistant/components/nrgkick/sensor.py @@ -7,6 +7,8 @@ from datetime import datetime, timedelta from typing import Any, cast +from nrgkick_api import ChargingStatus + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -43,22 +45,11 @@ WARNING_CODE_MAP, ) from .coordinator import NRGkickConfigEntry, NRGkickData, NRGkickDataUpdateCoordinator -from .entity import NRGkickEntity +from .entity import NRGkickEntity, get_nested_dict_value PARALLEL_UPDATES = 0 -def _get_nested_dict_value(data: Any, *keys: str) -> Any: - """Safely get a nested value from dict-like API responses.""" - current: Any = data - for key in keys: - try: - current = current.get(key) - except AttributeError: - return None - return current - - @dataclass(frozen=True, kw_only=True) class NRGkickSensorEntityDescription(SensorEntityDescription): """Class describing NRGkick sensor entities.""" @@ -157,7 +148,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, suggested_display_precision=2, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.info, "general", "rated_current" ), ), @@ -165,7 +156,7 @@ async def async_setup_entry( NRGkickSensorEntityDescription( key="connector_phase_count", translation_key="connector_phase_count", - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.info, "connector", "phase_count" ), ), @@ -176,7 +167,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, suggested_display_precision=2, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.info, "connector", "max_current" ), ), @@ -187,7 +178,7 @@ async def async_setup_entry( options=_enum_options_from_mapping(CONNECTOR_TYPE_MAP), entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: _map_code_to_translation_key( - cast(StateType, _get_nested_dict_value(data.info, "connector", "type")), + cast(StateType, get_nested_dict_value(data.info, "connector", "type")), CONNECTOR_TYPE_MAP, ), ), @@ -196,7 +187,7 @@ async def async_setup_entry( translation_key="connector_serial", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: _get_nested_dict_value(data.info, "connector", "serial"), + value_fn=lambda data: get_nested_dict_value(data.info, "connector", "serial"), ), # INFO - Grid NRGkickSensorEntityDescription( @@ -206,7 +197,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, suggested_display_precision=2, - value_fn=lambda data: _get_nested_dict_value(data.info, "grid", "voltage"), + value_fn=lambda data: get_nested_dict_value(data.info, "grid", "voltage"), ), NRGkickSensorEntityDescription( key="grid_frequency", @@ -215,7 +206,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.HERTZ, suggested_display_precision=2, - value_fn=lambda data: _get_nested_dict_value(data.info, "grid", "frequency"), + value_fn=lambda data: get_nested_dict_value(data.info, "grid", "frequency"), ), # INFO - Network NRGkickSensorEntityDescription( @@ -223,7 +214,7 @@ async def async_setup_entry( translation_key="network_ssid", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: _get_nested_dict_value(data.info, "network", "ssid"), + value_fn=lambda data: get_nested_dict_value(data.info, "network", "ssid"), ), NRGkickSensorEntityDescription( key="network_rssi", @@ -232,7 +223,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: _get_nested_dict_value(data.info, "network", "rssi"), + value_fn=lambda data: get_nested_dict_value(data.info, "network", "rssi"), ), # INFO - Cellular (optional, only if cellular module is available) NRGkickSensorEntityDescription( @@ -244,7 +235,7 @@ async def async_setup_entry( entity_registry_enabled_default=False, requires_sim_module=True, value_fn=lambda data: _map_code_to_translation_key( - cast(StateType, _get_nested_dict_value(data.info, "cellular", "mode")), + cast(StateType, get_nested_dict_value(data.info, "cellular", "mode")), CELLULAR_MODE_MAP, ), ), @@ -257,7 +248,7 @@ async def async_setup_entry( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, requires_sim_module=True, - value_fn=lambda data: _get_nested_dict_value(data.info, "cellular", "rssi"), + value_fn=lambda data: get_nested_dict_value(data.info, "cellular", "rssi"), ), NRGkickSensorEntityDescription( key="cellular_operator", @@ -265,7 +256,7 @@ async def async_setup_entry( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, requires_sim_module=True, - value_fn=lambda data: _get_nested_dict_value(data.info, "cellular", "operator"), + value_fn=lambda data: get_nested_dict_value(data.info, "cellular", "operator"), ), # VALUES - Energy NRGkickSensorEntityDescription( @@ -276,7 +267,7 @@ async def async_setup_entry( native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_display_precision=3, suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "energy", "total_charged_energy" ), ), @@ -288,7 +279,7 @@ async def async_setup_entry( native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_display_precision=3, suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "energy", "charged_energy" ), ), @@ -300,7 +291,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, suggested_display_precision=2, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "powerflow", "charging_voltage" ), ), @@ -311,7 +302,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, suggested_display_precision=2, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "powerflow", "charging_current" ), ), @@ -324,7 +315,7 @@ async def async_setup_entry( suggested_display_precision=2, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "powerflow", "grid_frequency" ), ), @@ -337,7 +328,7 @@ async def async_setup_entry( suggested_display_precision=2, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "powerflow", "peak_power" ), ), @@ -348,7 +339,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, suggested_display_precision=2, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "powerflow", "total_active_power" ), ), @@ -360,7 +351,7 @@ async def async_setup_entry( native_unit_of_measurement=UnitOfReactivePower.VOLT_AMPERE_REACTIVE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "powerflow", "total_reactive_power" ), ), @@ -372,7 +363,7 @@ async def async_setup_entry( native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "powerflow", "total_apparent_power" ), ), @@ -384,7 +375,7 @@ async def async_setup_entry( native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "powerflow", "total_power_factor" ), ), @@ -398,7 +389,7 @@ async def async_setup_entry( suggested_display_precision=2, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "powerflow", "l1", "voltage" ), ), @@ -409,7 +400,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, suggested_display_precision=2, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "powerflow", "l1", "current" ), ), @@ -420,7 +411,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, suggested_display_precision=2, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "powerflow", "l1", "active_power" ), ), @@ -432,7 +423,7 @@ async def async_setup_entry( native_unit_of_measurement=UnitOfReactivePower.VOLT_AMPERE_REACTIVE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "powerflow", "l1", "reactive_power" ), ), @@ -444,7 +435,7 @@ async def async_setup_entry( native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "powerflow", "l1", "apparent_power" ), ), @@ -456,7 +447,7 @@ async def async_setup_entry( native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "powerflow", "l1", "power_factor" ), ), @@ -470,7 +461,7 @@ async def async_setup_entry( suggested_display_precision=2, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "powerflow", "l2", "voltage" ), ), @@ -481,7 +472,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, suggested_display_precision=2, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "powerflow", "l2", "current" ), ), @@ -492,7 +483,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, suggested_display_precision=2, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "powerflow", "l2", "active_power" ), ), @@ -504,7 +495,7 @@ async def async_setup_entry( native_unit_of_measurement=UnitOfReactivePower.VOLT_AMPERE_REACTIVE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "powerflow", "l2", "reactive_power" ), ), @@ -516,7 +507,7 @@ async def async_setup_entry( native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "powerflow", "l2", "apparent_power" ), ), @@ -528,7 +519,7 @@ async def async_setup_entry( native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "powerflow", "l2", "power_factor" ), ), @@ -542,7 +533,7 @@ async def async_setup_entry( suggested_display_precision=2, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "powerflow", "l3", "voltage" ), ), @@ -553,7 +544,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, suggested_display_precision=2, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "powerflow", "l3", "current" ), ), @@ -564,7 +555,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, suggested_display_precision=2, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "powerflow", "l3", "active_power" ), ), @@ -576,7 +567,7 @@ async def async_setup_entry( native_unit_of_measurement=UnitOfReactivePower.VOLT_AMPERE_REACTIVE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "powerflow", "l3", "reactive_power" ), ), @@ -588,7 +579,7 @@ async def async_setup_entry( native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "powerflow", "l3", "apparent_power" ), ), @@ -600,7 +591,7 @@ async def async_setup_entry( native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "powerflow", "l3", "power_factor" ), ), @@ -614,7 +605,7 @@ async def async_setup_entry( suggested_display_precision=2, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "powerflow", "n", "current" ), ), @@ -624,7 +615,7 @@ async def async_setup_entry( translation_key="charging_rate", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "general", "charging_rate" ), ), @@ -632,11 +623,18 @@ async def async_setup_entry( key="vehicle_connected_since", translation_key="vehicle_connected_since", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda data: _seconds_to_stable_timestamp( - cast( - StateType, - _get_nested_dict_value(data.values, "general", "vehicle_connect_time"), + value_fn=lambda data: ( + _seconds_to_stable_timestamp( + cast( + StateType, + get_nested_dict_value( + data.values, "general", "vehicle_connect_time" + ), + ) ) + if get_nested_dict_value(data.values, "general", "status") + != ChargingStatus.STANDBY + else None ), ), NRGkickSensorEntityDescription( @@ -646,7 +644,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTime.SECONDS, suggested_unit_of_measurement=UnitOfTime.MINUTES, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "general", "vehicle_charging_time" ), ), @@ -656,7 +654,7 @@ async def async_setup_entry( device_class=SensorDeviceClass.ENUM, options=_enum_options_from_mapping(STATUS_MAP), value_fn=lambda data: _map_code_to_translation_key( - cast(StateType, _get_nested_dict_value(data.values, "general", "status")), + cast(StateType, get_nested_dict_value(data.values, "general", "status")), STATUS_MAP, ), ), @@ -666,7 +664,7 @@ async def async_setup_entry( entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, suggested_display_precision=0, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "general", "charge_count" ), ), @@ -678,7 +676,7 @@ async def async_setup_entry( entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: _map_code_to_translation_key( cast( - StateType, _get_nested_dict_value(data.values, "general", "rcd_trigger") + StateType, get_nested_dict_value(data.values, "general", "rcd_trigger") ), RCD_TRIGGER_MAP, ), @@ -691,8 +689,7 @@ async def async_setup_entry( entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: _map_code_to_translation_key( cast( - StateType, - _get_nested_dict_value(data.values, "general", "warning_code"), + StateType, get_nested_dict_value(data.values, "general", "warning_code") ), WARNING_CODE_MAP, ), @@ -705,7 +702,7 @@ async def async_setup_entry( entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: _map_code_to_translation_key( cast( - StateType, _get_nested_dict_value(data.values, "general", "error_code") + StateType, get_nested_dict_value(data.values, "general", "error_code") ), ERROR_CODE_MAP, ), @@ -718,7 +715,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "temperatures", "housing" ), ), @@ -729,7 +726,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "temperatures", "connector_l1" ), ), @@ -740,7 +737,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "temperatures", "connector_l2" ), ), @@ -751,7 +748,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "temperatures", "connector_l3" ), ), @@ -762,7 +759,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "temperatures", "domestic_plug_1" ), ), @@ -773,7 +770,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: _get_nested_dict_value( + value_fn=lambda data: get_nested_dict_value( data.values, "temperatures", "domestic_plug_2" ), ), diff --git a/homeassistant/components/nrgkick/strings.json b/homeassistant/components/nrgkick/strings.json index 434c07e5a31d9f..3da169ec74f40f 100644 --- a/homeassistant/components/nrgkick/strings.json +++ b/homeassistant/components/nrgkick/strings.json @@ -4,7 +4,10 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "json_api_disabled": "JSON API is disabled on the device. Enable it in the NRGkick mobile app under Extended \u2192 Local API \u2192 API Variants.", - "no_serial_number": "Device does not provide a serial number" + "no_serial_number": "Device does not provide a serial number", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "The device does not match the previous device" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -15,6 +18,37 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "[%key:component::nrgkick::config::step::user_auth::data_description::password%]", + "username": "[%key:component::nrgkick::config::step::user_auth::data_description::username%]" + }, + "description": "Reauthenticate with your NRGkick device.\n\nGet your username and password in the NRGkick mobile app:\n1. Open the NRGkick mobile app \u2192 Extended \u2192 Local API\n2. Under Authentication (JSON), check or set your username and password" + }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::nrgkick::config::step::user::data_description::host%]" + }, + "description": "Reconfigure your NRGkick device. This allows you to change the IP address or hostname of your NRGkick device." + }, + "reconfigure_auth": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "[%key:component::nrgkick::config::step::user_auth::data_description::password%]", + "username": "[%key:component::nrgkick::config::step::user_auth::data_description::username%]" + }, + "description": "[%key:component::nrgkick::config::step::user_auth::description%]" + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]" @@ -44,6 +78,27 @@ } }, "entity": { + "binary_sensor": { + "charge_permitted": { + "name": "Charge permitted" + } + }, + "device_tracker": { + "gps_tracker": { + "name": "GPS tracker" + } + }, + "number": { + "current_set": { + "name": "Charging current" + }, + "energy_limit": { + "name": "Energy limit" + }, + "phase_count": { + "name": "Phase count" + } + }, "sensor": { "cellular_mode": { "name": "Cellular mode", diff --git a/homeassistant/components/nsw_fuel_station/__init__.py b/homeassistant/components/nsw_fuel_station/__init__.py index 85e204b6f5145a..b1065d755f667d 100644 --- a/homeassistant/components/nsw_fuel_station/__init__.py +++ b/homeassistant/components/nsw_fuel_station/__init__.py @@ -2,23 +2,16 @@ from __future__ import annotations -from dataclasses import dataclass -import datetime -import logging - -from nsw_fuel import FuelCheckClient, FuelCheckError, Station +from nsw_fuel import FuelCheckClient from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DATA_NSW_FUEL_STATION - -_LOGGER = logging.getLogger(__name__) +from .coordinator import NSWFuelStationCoordinator DOMAIN = "nsw_fuel_station" -SCAN_INTERVAL = datetime.timedelta(hours=1) CONFIG_SCHEMA = cv.platform_only_config_schema(DOMAIN) @@ -27,46 +20,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the NSW Fuel Station platform.""" client = FuelCheckClient() - async def async_update_data(): - return await hass.async_add_executor_job(fetch_station_price_data, client) - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=None, - name="sensor", - update_interval=SCAN_INTERVAL, - update_method=async_update_data, - ) + coordinator = NSWFuelStationCoordinator(hass, client) hass.data[DATA_NSW_FUEL_STATION] = coordinator await coordinator.async_refresh() return True - - -@dataclass -class StationPriceData: - """Data structure for O(1) price and name lookups.""" - - stations: dict[int, Station] - prices: dict[tuple[int, str], float] - - -def fetch_station_price_data(client: FuelCheckClient) -> StationPriceData | None: - """Fetch fuel price and station data.""" - try: - raw_price_data = client.get_fuel_prices() - # Restructure prices and station details to be indexed by station code - # for O(1) lookup - return StationPriceData( - stations={s.code: s for s in raw_price_data.stations}, - prices={ - (p.station_code, p.fuel_type): p.price for p in raw_price_data.prices - }, - ) - - except FuelCheckError as exc: - raise UpdateFailed( - f"Failed to fetch NSW Fuel station price data: {exc}" - ) from exc diff --git a/homeassistant/components/nsw_fuel_station/coordinator.py b/homeassistant/components/nsw_fuel_station/coordinator.py new file mode 100644 index 00000000000000..c089e01aeea0c3 --- /dev/null +++ b/homeassistant/components/nsw_fuel_station/coordinator.py @@ -0,0 +1,65 @@ +"""Coordinator for the NSW Fuel Station integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +import datetime +import logging + +from nsw_fuel import FuelCheckClient, FuelCheckError, Station + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = datetime.timedelta(hours=1) + + +@dataclass +class StationPriceData: + """Data structure for O(1) price and name lookups.""" + + stations: dict[int, Station] + prices: dict[tuple[int, str], float] + + +class NSWFuelStationCoordinator(DataUpdateCoordinator[StationPriceData]): + """Class to manage fetching NSW fuel station data.""" + + config_entry: None + + def __init__(self, hass: HomeAssistant, client: FuelCheckClient) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=None, + name="sensor", + update_interval=SCAN_INTERVAL, + ) + self.client = client + + async def _async_update_data(self) -> StationPriceData: + """Fetch data from API.""" + return await self.hass.async_add_executor_job( + _fetch_station_price_data, self.client + ) + + +def _fetch_station_price_data(client: FuelCheckClient) -> StationPriceData: + """Fetch fuel price and station data.""" + try: + raw_price_data = client.get_fuel_prices() + # Restructure prices and station details to be indexed by station code + # for O(1) lookup + return StationPriceData( + stations={s.code: s for s in raw_price_data.stations}, + prices={ + (p.station_code, p.fuel_type): p.price for p in raw_price_data.prices + }, + ) + except FuelCheckError as exc: + raise UpdateFailed( + f"Failed to fetch NSW Fuel station price data: {exc}" + ) from exc diff --git a/homeassistant/components/nsw_fuel_station/sensor.py b/homeassistant/components/nsw_fuel_station/sensor.py index 7ae9b3a4d9f710..37e3e24b932b56 100644 --- a/homeassistant/components/nsw_fuel_station/sensor.py +++ b/homeassistant/components/nsw_fuel_station/sensor.py @@ -15,12 +15,10 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DATA_NSW_FUEL_STATION, StationPriceData +from .const import DATA_NSW_FUEL_STATION +from .coordinator import NSWFuelStationCoordinator _LOGGER = logging.getLogger(__name__) @@ -65,11 +63,7 @@ def setup_platform( station_id = config[CONF_STATION_ID] fuel_types = config[CONF_FUEL_TYPES] - coordinator = hass.data[DATA_NSW_FUEL_STATION] - - if coordinator.data is None: - _LOGGER.error("Initial fuel station price data not available") - return + coordinator: NSWFuelStationCoordinator = hass.data[DATA_NSW_FUEL_STATION] entities = [] for fuel_type in fuel_types: @@ -86,16 +80,14 @@ def setup_platform( add_entities(entities) -class StationPriceSensor( - CoordinatorEntity[DataUpdateCoordinator[StationPriceData]], SensorEntity -): +class StationPriceSensor(CoordinatorEntity[NSWFuelStationCoordinator], SensorEntity): """Implementation of a sensor that reports the fuel price for a station.""" _attr_attribution = "Data provided by NSW Government FuelCheck" def __init__( self, - coordinator: DataUpdateCoordinator[StationPriceData], + coordinator: NSWFuelStationCoordinator, station_id: int, fuel_type: str, ) -> None: @@ -114,9 +106,6 @@ def name(self) -> str: @property def native_value(self) -> float | None: """Return the state of the sensor.""" - if self.coordinator.data is None: - return None - prices = self.coordinator.data.prices return prices.get((self._station_id, self._fuel_type)) @@ -133,16 +122,13 @@ def native_unit_of_measurement(self) -> str: """Return the units of measurement.""" return f"{CURRENCY_CENT}/{UnitOfVolume.LITERS}" - def _get_station_name(self): - default_name = f"station {self._station_id}" - if self.coordinator.data is None: - return default_name - - station = self.coordinator.data.stations.get(self._station_id) - if station is None: - return default_name + def _get_station_name(self) -> str: + if ( + station := self.coordinator.data.stations.get(self._station_id) + ) is not None: + return station.name - return station.name + return f"station {self._station_id}" @property def unique_id(self) -> str | None: diff --git a/homeassistant/components/ntfy/__init__.py b/homeassistant/components/ntfy/__init__.py index e2edc3354f3efe..fc1196ebde764a 100644 --- a/homeassistant/components/ntfy/__init__.py +++ b/homeassistant/components/ntfy/__init__.py @@ -11,6 +11,7 @@ NtfyTimeoutError, NtfyUnauthorizedAuthenticationError, ) +from aiontfy.update import UpdateChecker from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant @@ -18,14 +19,27 @@ 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.hass_dict import HassKey from .const import DOMAIN -from .coordinator import NtfyConfigEntry, NtfyDataUpdateCoordinator +from .coordinator import ( + NtfyConfigEntry, + NtfyDataUpdateCoordinator, + NtfyLatestReleaseUpdateCoordinator, + NtfyRuntimeData, + NtfyVersionDataUpdateCoordinator, +) from .services import async_setup_services _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.EVENT, Platform.NOTIFY, Platform.SENSOR] +PLATFORMS: list[Platform] = [ + Platform.EVENT, + Platform.NOTIFY, + Platform.SENSOR, + Platform.UPDATE, +] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +NTFY_KEY: HassKey[NtfyLatestReleaseUpdateCoordinator] = HassKey(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -40,6 +54,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: NtfyConfigEntry) -> bool session = async_get_clientsession(hass, entry.data.get(CONF_VERIFY_SSL, True)) ntfy = Ntfy(entry.data[CONF_URL], session, token=entry.data.get(CONF_TOKEN)) + if NTFY_KEY not in hass.data: + update_checker = UpdateChecker(session) + update_coordinator = NtfyLatestReleaseUpdateCoordinator(hass, update_checker) + await update_coordinator.async_request_refresh() + hass.data[NTFY_KEY] = update_coordinator try: await ntfy.account() @@ -69,7 +88,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: NtfyConfigEntry) -> bool coordinator = NtfyDataUpdateCoordinator(hass, entry, ntfy) await coordinator.async_config_entry_first_refresh() - entry.runtime_data = coordinator + + version = NtfyVersionDataUpdateCoordinator(hass, entry, ntfy) + await version.async_config_entry_first_refresh() + + entry.runtime_data = NtfyRuntimeData(coordinator, version) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/ntfy/const.py b/homeassistant/components/ntfy/const.py index 5fb500917d67cf..753a46bdae7933 100644 --- a/homeassistant/components/ntfy/const.py +++ b/homeassistant/components/ntfy/const.py @@ -3,7 +3,7 @@ from typing import Final DOMAIN = "ntfy" -DEFAULT_URL: Final = "https://ntfy.sh" +DEFAULT_URL: Final = "https://ntfy.sh/" CONF_TOPIC = "topic" CONF_PRIORITY = "filter_priority" diff --git a/homeassistant/components/ntfy/coordinator.py b/homeassistant/components/ntfy/coordinator.py index a52f1b06f41ad3..2421b6b8061b6c 100644 --- a/homeassistant/components/ntfy/coordinator.py +++ b/homeassistant/components/ntfy/coordinator.py @@ -2,16 +2,20 @@ from __future__ import annotations +from abc import abstractmethod +from dataclasses import dataclass from datetime import timedelta import logging -from aiontfy import Account as NtfyAccount, Ntfy +from aiontfy import Account as NtfyAccount, Ntfy, Version from aiontfy.exceptions import ( NtfyConnectionError, NtfyHTTPError, + NtfyNotFoundPageError, NtfyTimeoutError, NtfyUnauthorizedAuthenticationError, ) +from aiontfy.update import LatestRelease, UpdateChecker, UpdateCheckerError from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -22,13 +26,22 @@ _LOGGER = logging.getLogger(__name__) -type NtfyConfigEntry = ConfigEntry[NtfyDataUpdateCoordinator] +type NtfyConfigEntry = ConfigEntry[NtfyRuntimeData] -class NtfyDataUpdateCoordinator(DataUpdateCoordinator[NtfyAccount]): - """Ntfy data update coordinator.""" +@dataclass +class NtfyRuntimeData: + """Holds ntfy runtime data.""" + + account: NtfyDataUpdateCoordinator + version: NtfyVersionDataUpdateCoordinator + + +class BaseDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): + """Ntfy base coordinator.""" config_entry: NtfyConfigEntry + update_interval: timedelta def __init__( self, hass: HomeAssistant, config_entry: NtfyConfigEntry, ntfy: Ntfy @@ -39,21 +52,19 @@ def __init__( _LOGGER, config_entry=config_entry, name=DOMAIN, - update_interval=timedelta(minutes=15), + update_interval=self.update_interval, ) self.ntfy = ntfy - async def _async_update_data(self) -> NtfyAccount: - """Fetch account data from ntfy.""" + @abstractmethod + async def async_update_data(self) -> _DataT: + """Fetch the latest data from the source.""" + async def _async_update_data(self) -> _DataT: + """Fetch the latest data from the source.""" try: - return await self.ntfy.account() - except NtfyUnauthorizedAuthenticationError as e: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="authentication_error", - ) from e + return await self.async_update_data() except NtfyHTTPError as e: _LOGGER.debug("Error %s: %s [%s]", e.code, e.error, e.link) raise UpdateFailed( @@ -72,3 +83,62 @@ async def _async_update_data(self) -> NtfyAccount: translation_domain=DOMAIN, translation_key="timeout_error", ) from e + + +class NtfyDataUpdateCoordinator(BaseDataUpdateCoordinator[NtfyAccount]): + """Ntfy data update coordinator.""" + + update_interval = timedelta(minutes=15) + + async def async_update_data(self) -> NtfyAccount: + """Fetch account data from ntfy.""" + + try: + return await self.ntfy.account() + except NtfyUnauthorizedAuthenticationError as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_error", + ) from e + + +class NtfyVersionDataUpdateCoordinator(BaseDataUpdateCoordinator[Version | None]): + """Ntfy data update coordinator.""" + + update_interval = timedelta(hours=3) + + async def async_update_data(self) -> Version | None: + """Fetch version data from ntfy.""" + try: + version = await self.ntfy.version() + except NtfyUnauthorizedAuthenticationError, NtfyNotFoundPageError: + # /v1/version endpoint is only accessible to admins and + # available in ntfy since version 2.17.0 + return None + return version + + +class NtfyLatestReleaseUpdateCoordinator(DataUpdateCoordinator[LatestRelease]): + """Ntfy latest release update coordinator.""" + + def __init__(self, hass: HomeAssistant, update_checker: UpdateChecker) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=None, + name=DOMAIN, + update_interval=timedelta(hours=3), + ) + self.update_checker = update_checker + + async def _async_update_data(self) -> LatestRelease: + """Fetch latest release data.""" + + try: + return await self.update_checker.latest_release() + except UpdateCheckerError as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_check_failed", + ) from e diff --git a/homeassistant/components/ntfy/entity.py b/homeassistant/components/ntfy/entity.py index d03d953799f058..856303cd60dd50 100644 --- a/homeassistant/components/ntfy/entity.py +++ b/homeassistant/components/ntfy/entity.py @@ -7,10 +7,11 @@ from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_URL from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_TOPIC, DOMAIN -from .coordinator import NtfyConfigEntry +from .coordinator import BaseDataUpdateCoordinator, NtfyConfigEntry class NtfyBaseEntity(Entity): @@ -38,6 +39,29 @@ def __init__( identifiers={(DOMAIN, f"{config_entry.entry_id}_{subentry.subentry_id}")}, via_device=(DOMAIN, config_entry.entry_id), ) - self.ntfy = config_entry.runtime_data.ntfy + self.ntfy = config_entry.runtime_data.account.ntfy self.config_entry = config_entry self.subentry = subentry + + +class NtfyCommonBaseEntity(CoordinatorEntity[BaseDataUpdateCoordinator]): + """Base entity for common entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: BaseDataUpdateCoordinator, + description: EntityDescription, + ) -> None: + """Initialize entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + manufacturer="ntfy LLC", + model="ntfy", + configuration_url=URL(coordinator.config_entry.data[CONF_URL]) / "app", + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + ) diff --git a/homeassistant/components/ntfy/icons.json b/homeassistant/components/ntfy/icons.json index 30750a45155960..cb9348cf85048d 100644 --- a/homeassistant/components/ntfy/icons.json +++ b/homeassistant/components/ntfy/icons.json @@ -81,6 +81,9 @@ "service": "mdi:comment-remove" }, "publish": { + "sections": { + "actions": "mdi:gesture-tap-button" + }, "service": "mdi:send" } } diff --git a/homeassistant/components/ntfy/manifest.json b/homeassistant/components/ntfy/manifest.json index 1be3c30ba49e2d..b327c1e2b93eed 100644 --- a/homeassistant/components/ntfy/manifest.json +++ b/homeassistant/components/ntfy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/ntfy", "integration_type": "service", "iot_class": "cloud_push", - "loggers": ["aionfty"], + "loggers": ["aiontfy"], "quality_scale": "platinum", - "requirements": ["aiontfy==0.7.0"] + "requirements": ["aiontfy==0.8.1"] } diff --git a/homeassistant/components/ntfy/notify.py b/homeassistant/components/ntfy/notify.py index cc3faba454a223..d23ebcc8b167fe 100644 --- a/homeassistant/components/ntfy/notify.py +++ b/homeassistant/components/ntfy/notify.py @@ -27,7 +27,14 @@ from .const import DOMAIN from .coordinator import NtfyConfigEntry from .entity import NtfyBaseEntity -from .services import ATTR_ATTACH_FILE, ATTR_FILENAME, ATTR_SEQUENCE_ID +from .services import ( + ACTIONS_MAP, + ATTR_ACTION, + ATTR_ACTIONS, + ATTR_ATTACH_FILE, + ATTR_FILENAME, + ATTR_SEQUENCE_ID, +) _LOGGER = logging.getLogger(__name__) @@ -105,6 +112,15 @@ async def publish(self, **kwargs: Any) -> None: params.setdefault(ATTR_FILENAME, media.path.name) + actions: list[dict[str, Any]] | None = params.get(ATTR_ACTIONS) + if actions: + params["actions"] = [ + ACTIONS_MAP[action[ATTR_ACTION]]( + **{k: v for k, v in action.items() if k != ATTR_ACTION} + ) + for action in actions + ] + msg = Message(topic=self.topic, **params) try: await self.ntfy.publish(msg, attachment) diff --git a/homeassistant/components/ntfy/sensor.py b/homeassistant/components/ntfy/sensor.py index cb005eb84d8e01..89a30493c1f065 100644 --- a/homeassistant/components/ntfy/sensor.py +++ b/homeassistant/components/ntfy/sensor.py @@ -7,22 +7,19 @@ from enum import StrEnum from aiontfy import Account as NtfyAccount -from yarl import URL from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, ) -from homeassistant.const import CONF_URL, EntityCategory, UnitOfInformation, UnitOfTime +from homeassistant.const import EntityCategory, UnitOfInformation, UnitOfTime 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 .const import DOMAIN from .coordinator import NtfyConfigEntry, NtfyDataUpdateCoordinator +from .entity import NtfyCommonBaseEntity PARALLEL_UPDATES = 0 @@ -233,38 +230,19 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" - coordinator = config_entry.runtime_data + coordinator = config_entry.runtime_data.account async_add_entities( NtfySensorEntity(coordinator, description) for description in SENSOR_DESCRIPTIONS ) -class NtfySensorEntity(CoordinatorEntity[NtfyDataUpdateCoordinator], SensorEntity): +class NtfySensorEntity(NtfyCommonBaseEntity, SensorEntity): """Representation of a ntfy sensor entity.""" entity_description: NtfySensorEntityDescription coordinator: NtfyDataUpdateCoordinator - _attr_has_entity_name = True - - def __init__( - self, - coordinator: NtfyDataUpdateCoordinator, - description: NtfySensorEntityDescription, - ) -> None: - """Initialize a sensor entity.""" - super().__init__(coordinator) - self.entity_description = description - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - manufacturer="ntfy LLC", - model="ntfy", - configuration_url=URL(coordinator.config_entry.data[CONF_URL]) / "app", - identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, - ) - @property def native_value(self) -> StateType: """Return the state of the sensor.""" diff --git a/homeassistant/components/ntfy/services.py b/homeassistant/components/ntfy/services.py index c3619f5f0b7d16..45d87e5b9bb334 100644 --- a/homeassistant/components/ntfy/services.py +++ b/homeassistant/components/ntfy/services.py @@ -3,6 +3,7 @@ from datetime import timedelta from typing import Any +from aiontfy import BroadcastAction, CopyAction, HttpAction, ViewAction import voluptuous as vol from yarl import URL @@ -34,6 +35,28 @@ ATTR_FILENAME = "filename" GRP_ATTACHMENT = "attachment" MSG_ATTACHMENT = "Only one attachment source is allowed: URL or local file" +ATTR_ACTIONS = "actions" +ATTR_ACTION = "action" +ATTR_VIEW = "view" +ATTR_BROADCAST = "broadcast" +ATTR_HTTP = "http" +ATTR_LABEL = "label" +ATTR_URL = "url" +ATTR_CLEAR = "clear" +ATTR_INTENT = "intent" +ATTR_EXTRAS = "extras" +ATTR_METHOD = "method" +ATTR_HEADERS = "headers" +ATTR_BODY = "body" +ATTR_VALUE = "value" +ATTR_COPY = "copy" +ACTIONS_MAP = { + ATTR_VIEW: ViewAction, + ATTR_BROADCAST: BroadcastAction, + ATTR_HTTP: HttpAction, + ATTR_COPY: CopyAction, +} +MAX_ACTIONS_ALLOWED = 3 # ntfy only supports up to 3 actions per notification def validate_filename(params: dict[str, Any]) -> dict[str, Any]: @@ -45,6 +68,40 @@ def validate_filename(params: dict[str, Any]) -> dict[str, Any]: return params +ACTION_SCHEMA = vol.Schema( + { + vol.Required(ATTR_LABEL): cv.string, + vol.Optional(ATTR_CLEAR, default=False): cv.boolean, + } +) +VIEW_SCHEMA = ACTION_SCHEMA.extend( + { + vol.Required(ATTR_ACTION): vol.Equal("view"), + vol.Required(ATTR_URL): vol.All(vol.Url(), vol.Coerce(URL)), + } +) +BROADCAST_SCHEMA = ACTION_SCHEMA.extend( + { + vol.Required(ATTR_ACTION): vol.Equal("broadcast"), + vol.Optional(ATTR_INTENT): cv.string, + vol.Optional(ATTR_EXTRAS): dict[str, str], + } +) +HTTP_SCHEMA = VIEW_SCHEMA.extend( + { + vol.Required(ATTR_ACTION): vol.Equal("http"), + vol.Optional(ATTR_METHOD): cv.string, + vol.Optional(ATTR_HEADERS): dict[str, str], + vol.Optional(ATTR_BODY): cv.string, + } +) +COPY_SCHEMA = ACTION_SCHEMA.extend( + { + vol.Required(ATTR_ACTION): vol.Equal("copy"), + vol.Required(ATTR_VALUE): cv.string, + } +) + SERVICE_PUBLISH_SCHEMA = vol.All( cv.make_entity_service_schema( { @@ -69,6 +126,14 @@ def validate_filename(params: dict[str, Any]) -> dict[str, Any]: ATTR_ATTACH_FILE, GRP_ATTACHMENT, MSG_ATTACHMENT ): MediaSelector({"accept": ["*/*"]}), vol.Optional(ATTR_FILENAME): cv.string, + vol.Optional(ATTR_ACTIONS): vol.All( + cv.ensure_list, + vol.Length( + max=MAX_ACTIONS_ALLOWED, + msg="Too many actions defined. A maximum of 3 is supported", + ), + [vol.Any(VIEW_SCHEMA, BROADCAST_SCHEMA, HTTP_SCHEMA, COPY_SCHEMA)], + ), } ), validate_filename, diff --git a/homeassistant/components/ntfy/services.yaml b/homeassistant/components/ntfy/services.yaml index d6664b70f5bf46..be3d35e8c84094 100644 --- a/homeassistant/components/ntfy/services.yaml +++ b/homeassistant/components/ntfy/services.yaml @@ -99,6 +99,65 @@ publish: type: url autocomplete: url example: https://example.org/logo.png + actions: + selector: + object: + label_field: "label" + description_field: "url" + multiple: true + translation_key: actions + fields: + action: + required: true + selector: + select: + options: + - value: view + label: Open website/app + - value: http + label: Send HTTP request + - value: broadcast + label: Send Android broadcast + - value: copy + label: Copy to clipboard + translation_key: action_type + mode: dropdown + label: + selector: + text: + required: true + clear: + selector: + boolean: + url: + selector: + text: + type: url + method: + selector: + select: + options: + - GET + - POST + - PUT + - DELETE + custom_value: true + headers: + selector: + object: + body: + selector: + text: + multiline: true + intent: + selector: + text: + extras: + selector: + object: + value: + selector: + text: sequence_id: required: false selector: diff --git a/homeassistant/components/ntfy/strings.json b/homeassistant/components/ntfy/strings.json index ce6f385f95b3b2..689c3194cb31b5 100644 --- a/homeassistant/components/ntfy/strings.json +++ b/homeassistant/components/ntfy/strings.json @@ -261,6 +261,11 @@ "supporter": "Supporter" } } + }, + "update": { + "update": { + "name": "ntfy version" + } } }, "exceptions": { @@ -302,6 +307,9 @@ }, "timeout_error": { "message": "Failed to connect to ntfy service due to a connection timeout" + }, + "update_check_failed": { + "message": "Failed to check for latest ntfy update" } }, "issues": { @@ -318,6 +326,50 @@ } }, "selector": { + "actions": { + "fields": { + "action": { + "description": "Select the type of action to add to the notification", + "name": "Action type" + }, + "body": { + "description": "The body of the HTTP request for `http` actions.", + "name": "HTTP body" + }, + "clear": { + "description": "Clear notification after action button is tapped", + "name": "Clear notification" + }, + "extras": { + "description": "Extras to include in the intent as key-value pairs for 'broadcast' actions", + "name": "Intent extras" + }, + "headers": { + "description": "Additional HTTP headers as key-value pairs for 'http' actions", + "name": "HTTP headers" + }, + "intent": { + "description": "Android intent to send when the 'broadcast' action is triggered", + "name": "Intent" + }, + "label": { + "description": "Label of the action button", + "name": "Label" + }, + "method": { + "description": "HTTP method to use for the 'http' action", + "name": "HTTP method" + }, + "url": { + "description": "URL to open for the 'view' action or to request for the 'http' action", + "name": "URL" + }, + "value": { + "description": "Value to copy to clipboard when the 'copy' action is triggered", + "name": "Value" + } + } + }, "priority": { "options": { "1": "Minimum", @@ -350,8 +402,12 @@ "name": "Delete notification" }, "publish": { - "description": "Publishes a notification message to a ntfy topic", + "description": "Publishes a notification message to a ntfy topic.", "fields": { + "actions": { + "description": "Up to three actions (`view`, `broadcast`, `http`, or `copy`) can be added as buttons below the notification. Actions are executed when the corresponding button is tapped or clicked.", + "name": "Action buttons" + }, "attach": { "description": "Attach images or other files by URL.", "name": "Attachment URL" diff --git a/homeassistant/components/ntfy/update.py b/homeassistant/components/ntfy/update.py new file mode 100644 index 00000000000000..039be5a5096418 --- /dev/null +++ b/homeassistant/components/ntfy/update.py @@ -0,0 +1,116 @@ +"""Update platform for the ntfy integration.""" + +from __future__ import annotations + +from enum import StrEnum + +from homeassistant.components.update import ( + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) +from homeassistant.const import CONF_URL, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import NTFY_KEY +from .const import DEFAULT_URL +from .coordinator import ( + NtfyConfigEntry, + NtfyLatestReleaseUpdateCoordinator, + NtfyVersionDataUpdateCoordinator, +) +from .entity import NtfyCommonBaseEntity + +PARALLEL_UPDATES = 0 + + +class NtfyUpdate(StrEnum): + """Ntfy update.""" + + UPDATE = "update" + + +DESCRIPTION = UpdateEntityDescription( + key=NtfyUpdate.UPDATE, + translation_key=NtfyUpdate.UPDATE, + entity_category=EntityCategory.DIAGNOSTIC, +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NtfyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up update platform.""" + if ( + entry.data[CONF_URL] != DEFAULT_URL + and (version_coordinator := entry.runtime_data.version).data is not None + ): + update_coordinator = hass.data[NTFY_KEY] + async_add_entities( + [NtfyUpdateEntity(version_coordinator, update_coordinator, DESCRIPTION)] + ) + + +class NtfyUpdateEntity(NtfyCommonBaseEntity, UpdateEntity): + """Representation of an update entity.""" + + _attr_supported_features = UpdateEntityFeature.RELEASE_NOTES + coordinator: NtfyVersionDataUpdateCoordinator + + def __init__( + self, + coordinator: NtfyVersionDataUpdateCoordinator, + update_checker: NtfyLatestReleaseUpdateCoordinator, + description: EntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator, description) + self.update_checker = update_checker + if self._attr_device_info and self.installed_version: + self._attr_device_info.update({"sw_version": self.installed_version}) + + @property + def installed_version(self) -> str | None: + """Current version.""" + return self.coordinator.data.version if self.coordinator.data else None + + @property + def title(self) -> str | None: + """Title of the release.""" + + return f"ntfy {self.update_checker.data.name}" + + @property + def release_url(self) -> str | None: + """URL to the full release notes.""" + + return self.update_checker.data.html_url + + @property + def latest_version(self) -> str | None: + """Latest version.""" + + return self.update_checker.data.tag_name.removeprefix("v") + + async def async_release_notes(self) -> str | None: + """Return the release notes.""" + return self.update_checker.data.body + + async def async_added_to_hass(self) -> None: + """When entity is added to hass. + + Register extra update listener for the update checker coordinator. + """ + await super().async_added_to_hass() + self.async_on_remove( + self.update_checker.async_add_listener(self._handle_coordinator_update) + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.update_checker.last_update_success diff --git a/homeassistant/components/nuheat/__init__.py b/homeassistant/components/nuheat/__init__.py index fb17e6b45bf4b3..21c7ca79a1fad9 100644 --- a/homeassistant/components/nuheat/__init__.py +++ b/homeassistant/components/nuheat/__init__.py @@ -1,6 +1,5 @@ """Support for NuHeat thermostats.""" -from datetime import timedelta from http import HTTPStatus import logging @@ -11,14 +10,14 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import CONF_SERIAL_NUMBER, DOMAIN, PLATFORMS +from .coordinator import NuHeatCoordinator _LOGGER = logging.getLogger(__name__) -def _get_thermostat(api, serial_number): +def _get_thermostat(api: nuheat.NuHeat, serial_number: str) -> nuheat.NuHeatThermostat: """Authenticate and create the thermostat object.""" api.authenticate() return api.get_thermostat(serial_number) @@ -29,9 +28,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: conf = entry.data - username = conf[CONF_USERNAME] - password = conf[CONF_PASSWORD] - serial_number = conf[CONF_SERIAL_NUMBER] + username: str = conf[CONF_USERNAME] + password: str = conf[CONF_PASSWORD] + serial_number: str = conf[CONF_SERIAL_NUMBER] api = nuheat.NuHeat(username, password) @@ -53,18 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Failed to login to nuheat: %s", ex) return False - async def _async_update_data(): - """Fetch data from API endpoint.""" - await hass.async_add_executor_job(thermostat.get_data) - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name=f"nuheat {serial_number}", - update_method=_async_update_data, - update_interval=timedelta(minutes=5), - ) + coordinator = NuHeatCoordinator(hass, entry, thermostat) hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = (thermostat, coordinator) diff --git a/homeassistant/components/nuheat/climate.py b/homeassistant/components/nuheat/climate.py index 6a38bb160be362..e666e4be0cd03f 100644 --- a/homeassistant/components/nuheat/climate.py +++ b/homeassistant/components/nuheat/climate.py @@ -27,6 +27,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER, NUHEAT_API_STATE_SHIFT_DELAY +from .coordinator import NuHeatCoordinator _LOGGER = logging.getLogger(__name__) @@ -69,7 +70,7 @@ async def async_setup_entry( async_add_entities([entity], True) -class NuHeatThermostat(CoordinatorEntity, ClimateEntity): +class NuHeatThermostat(CoordinatorEntity[NuHeatCoordinator], ClimateEntity): """Representation of a NuHeat Thermostat.""" _attr_hvac_modes = OPERATION_LIST @@ -98,7 +99,7 @@ def temperature_unit(self) -> str: return UnitOfTemperature.FAHRENHEIT @property - def current_temperature(self): + def current_temperature(self) -> int | None: """Return the current temperature.""" if self._temperature_unit == "C": return self._thermostat.celsius @@ -146,7 +147,7 @@ def max_temp(self) -> float: return self._thermostat.max_fahrenheit @property - def target_temperature(self): + def target_temperature(self) -> int: """Return the currently programmed temperature.""" if self._temperature_unit == "C": return nuheat_to_celsius(self._target_temperature) diff --git a/homeassistant/components/nuheat/coordinator.py b/homeassistant/components/nuheat/coordinator.py new file mode 100644 index 00000000000000..6555f7376ed116 --- /dev/null +++ b/homeassistant/components/nuheat/coordinator.py @@ -0,0 +1,42 @@ +"""DataUpdateCoordinator for NuHeat thermostats.""" + +from datetime import timedelta +import logging + +import nuheat + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import CONF_SERIAL_NUMBER + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(minutes=5) + + +class NuHeatCoordinator(DataUpdateCoordinator[None]): + """Coordinator for NuHeat thermostat data.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + thermostat: nuheat.NuHeatThermostat, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=f"nuheat {entry.data[CONF_SERIAL_NUMBER]}", + update_interval=SCAN_INTERVAL, + ) + self.thermostat = thermostat + + async def _async_update_data(self) -> None: + """Fetch data from API endpoint.""" + await self.hass.async_add_executor_job(self.thermostat.get_data) diff --git a/homeassistant/components/nuki/binary_sensor.py b/homeassistant/components/nuki/binary_sensor.py index 4bdc2a1515605d..7ba908c13e48ff 100644 --- a/homeassistant/components/nuki/binary_sensor.py +++ b/homeassistant/components/nuki/binary_sensor.py @@ -70,7 +70,7 @@ def door_sensor_state_name(self): return self._nuki_device.door_sensor_state_name @property - def is_on(self): + def is_on(self) -> bool: """Return true if the door is open.""" return self.door_sensor_state == STATE_DOORSENSOR_OPENED diff --git a/homeassistant/components/numato/binary_sensor.py b/homeassistant/components/numato/binary_sensor.py index 0f4ea23e722cb4..c1c251e0074344 100644 --- a/homeassistant/components/numato/binary_sensor.py +++ b/homeassistant/components/numato/binary_sensor.py @@ -121,7 +121,7 @@ def _async_update_state(self, level): self.async_write_ha_state() @property - def is_on(self): + def is_on(self) -> bool: """Return the state of the entity.""" return self._state != self._invert_logic diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 83777d47322a52..78ee067bc55edd 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -272,7 +272,7 @@ class NumberDeviceClass(StrEnum): NITROGEN_DIOXIDE = "nitrogen_dioxide" """Amount of NO2. - Unit of measurement: `ppb` (parts per billion), `μg/m³` + Unit of measurement: `ppb` (parts per billion), `ppm` (parts per million), `μg/m³` """ NITROGEN_MONOXIDE = "nitrogen_monoxide" @@ -544,6 +544,7 @@ class NumberDeviceClass(StrEnum): NumberDeviceClass.MOISTURE: {PERCENTAGE}, NumberDeviceClass.NITROGEN_DIOXIDE: { CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, }, NumberDeviceClass.NITROGEN_MONOXIDE: { diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index 1963265d7b575d..90daacaaa34ae8 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -3,13 +3,11 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import timedelta import logging from typing import TYPE_CHECKING -from aionut import AIONUTClient, NUTError, NUTLoginError +from aionut import AIONUTClient, NUTError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ALIAS, CONF_HOST, @@ -21,29 +19,17 @@ EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, INTEGRATION_SUPPORTED_COMMANDS, PLATFORMS +from .coordinator import NutConfigEntry, NutCoordinator, NutRuntimeData NUT_FAKE_SERIAL = ["unknown", "blank"] _LOGGER = logging.getLogger(__name__) -type NutConfigEntry = ConfigEntry[NutRuntimeData] - - -@dataclass -class NutRuntimeData: - """Runtime data definition.""" - - coordinator: DataUpdateCoordinator - data: PyNUTData - unique_id: str - user_available_commands: set[str] - async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: """Set up Network UPS Tools (NUT) from a config entry.""" @@ -73,36 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: entry.async_on_unload(data.async_shutdown) - async def async_update_data() -> dict[str, str]: - """Fetch data from NUT.""" - try: - return await data.async_update() - except NUTLoginError as err: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="device_authentication", - translation_placeholders={ - "err": str(err), - }, - ) from err - except NUTError as err: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="data_fetch_error", - translation_placeholders={ - "err": str(err), - }, - ) from err - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name="NUT resource status", - update_method=async_update_data, - update_interval=timedelta(seconds=60), - always_update=False, - ) + coordinator = NutCoordinator(hass, data, entry) # Fetch initial data so we have data when entities subscribe await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/nut/button.py b/homeassistant/components/nut/button.py index 0708056b2e386b..7f4a5cdf073246 100644 --- a/homeassistant/components/nut/button.py +++ b/homeassistant/components/nut/button.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import NutConfigEntry +from .coordinator import NutConfigEntry from .entity import NUTBaseEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/nut/coordinator.py b/homeassistant/components/nut/coordinator.py new file mode 100644 index 00000000000000..4ecfb9f3f90a71 --- /dev/null +++ b/homeassistant/components/nut/coordinator.py @@ -0,0 +1,79 @@ +"""The NUT coordinator.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import TYPE_CHECKING + +from aionut import NUTError, NUTLoginError + +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 + +if TYPE_CHECKING: + from . import PyNUTData + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class NutRuntimeData: + """Runtime data definition.""" + + coordinator: NutCoordinator + data: PyNUTData + unique_id: str + user_available_commands: set[str] + + +type NutConfigEntry = ConfigEntry[NutRuntimeData] + + +class NutCoordinator(DataUpdateCoordinator[dict[str, str]]): + """Coordinator for NUT data.""" + + config_entry: NutConfigEntry + + def __init__( + self, + hass: HomeAssistant, + data: PyNUTData, + config_entry: NutConfigEntry, + ) -> None: + """Initialize NUT coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name="NUT resource status", + update_interval=timedelta(seconds=60), + always_update=False, + ) + self._data = data + + async def _async_update_data(self) -> dict[str, str]: + """Fetch data from NUT.""" + try: + return await self._data.async_update() + except NUTLoginError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="device_authentication", + translation_placeholders={ + "err": str(err), + }, + ) from err + except NUTError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="data_fetch_error", + translation_placeholders={ + "err": str(err), + }, + ) from err diff --git a/homeassistant/components/nut/device_action.py b/homeassistant/components/nut/device_action.py index c622e63a12c7fb..5d613fa2b74ad2 100644 --- a/homeassistant/components/nut/device_action.py +++ b/homeassistant/components/nut/device_action.py @@ -13,8 +13,8 @@ from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import NutConfigEntry, NutRuntimeData from .const import DOMAIN, INTEGRATION_SUPPORTED_COMMANDS +from .coordinator import NutConfigEntry, NutRuntimeData ACTION_TYPES = {cmd.replace(".", "_") for cmd in INTEGRATION_SUPPORTED_COMMANDS} diff --git a/homeassistant/components/nut/diagnostics.py b/homeassistant/components/nut/diagnostics.py index ec59fa65c227b1..d7a266a5b4194b 100644 --- a/homeassistant/components/nut/diagnostics.py +++ b/homeassistant/components/nut/diagnostics.py @@ -11,8 +11,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import NutConfigEntry from .const import DOMAIN +from .coordinator import NutConfigEntry TO_REDACT = {CONF_PASSWORD, CONF_USERNAME} diff --git a/homeassistant/components/nut/entity.py b/homeassistant/components/nut/entity.py index e6536d8aad6f61..7ade4dcb3bf6aa 100644 --- a/homeassistant/components/nut/entity.py +++ b/homeassistant/components/nut/entity.py @@ -13,13 +13,11 @@ ) from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import PyNUTData from .const import DOMAIN +from .coordinator import NutCoordinator NUT_DEV_INFO_TO_DEV_INFO: dict[str, str] = { "manufacturer": ATTR_MANUFACTURER, @@ -29,14 +27,14 @@ } -class NUTBaseEntity(CoordinatorEntity[DataUpdateCoordinator]): +class NUTBaseEntity(CoordinatorEntity[NutCoordinator]): """NUT base entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: DataUpdateCoordinator, + coordinator: NutCoordinator, entity_description: EntityDescription, data: PyNUTData, unique_id: str, diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 11b646f86a1413..8ed64416547176 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -25,8 +25,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import NutConfigEntry from .const import KEY_STATUS, KEY_STATUS_DISPLAY, STATE_TYPES +from .coordinator import NutConfigEntry from .entity import NUTBaseEntity # Coordinator is used to centralize the data updates diff --git a/homeassistant/components/nut/switch.py b/homeassistant/components/nut/switch.py index 924a596cc8ee38..0964a225d026f4 100644 --- a/homeassistant/components/nut/switch.py +++ b/homeassistant/components/nut/switch.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import NutConfigEntry +from .coordinator import NutConfigEntry from .entity import NUTBaseEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/nx584/binary_sensor.py b/homeassistant/components/nx584/binary_sensor.py index 91d50591dfb9b9..b3292bde64c138 100644 --- a/homeassistant/components/nx584/binary_sensor.py +++ b/homeassistant/components/nx584/binary_sensor.py @@ -103,7 +103,7 @@ def name(self): return self._zone["name"] @property - def is_on(self): + def is_on(self) -> bool: """Return true if the binary sensor is on.""" # True means "faulted" or "open" or "abnormal state" return self._zone["state"] diff --git a/homeassistant/components/nzbget/switch.py b/homeassistant/components/nzbget/switch.py index 0796f628507ee1..a4b2dde4c47938 100644 --- a/homeassistant/components/nzbget/switch.py +++ b/homeassistant/components/nzbget/switch.py @@ -57,7 +57,7 @@ def __init__( ) @property - def is_on(self): + def is_on(self) -> bool: """Return the state of the switch.""" return not self.coordinator.data["status"].get("DownloadPaused", False) diff --git a/homeassistant/components/occupancy/__init__.py b/homeassistant/components/occupancy/__init__.py new file mode 100644 index 00000000000000..d9c1e38fd9303a --- /dev/null +++ b/homeassistant/components/occupancy/__init__.py @@ -0,0 +1,17 @@ +"""Integration for occupancy triggers.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +DOMAIN = "occupancy" +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/occupancy/condition.py b/homeassistant/components/occupancy/condition.py new file mode 100644 index 00000000000000..b4260798d06784 --- /dev/null +++ b/homeassistant/components/occupancy/condition.py @@ -0,0 +1,25 @@ +"""Provides conditions for occupancy.""" + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.condition import Condition, make_entity_state_condition + +_OCCUPANCY_DOMAIN_SPECS = { + BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.OCCUPANCY) +} + + +CONDITIONS: dict[str, type[Condition]] = { + "is_detected": make_entity_state_condition(_OCCUPANCY_DOMAIN_SPECS, STATE_ON), + "is_not_detected": make_entity_state_condition(_OCCUPANCY_DOMAIN_SPECS, STATE_OFF), +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the conditions for occupancy.""" + return CONDITIONS diff --git a/homeassistant/components/occupancy/conditions.yaml b/homeassistant/components/occupancy/conditions.yaml new file mode 100644 index 00000000000000..1f3cb7346b07f3 --- /dev/null +++ b/homeassistant/components/occupancy/conditions.yaml @@ -0,0 +1,24 @@ +.condition_common_fields: &condition_common_fields + behavior: + required: true + default: any + selector: + select: + translation_key: condition_behavior + options: + - all + - any + +is_detected: + fields: *condition_common_fields + target: + entity: + - domain: binary_sensor + device_class: occupancy + +is_not_detected: + fields: *condition_common_fields + target: + entity: + - domain: binary_sensor + device_class: occupancy diff --git a/homeassistant/components/occupancy/icons.json b/homeassistant/components/occupancy/icons.json new file mode 100644 index 00000000000000..d12ae545603404 --- /dev/null +++ b/homeassistant/components/occupancy/icons.json @@ -0,0 +1,18 @@ +{ + "conditions": { + "is_detected": { + "condition": "mdi:home-account" + }, + "is_not_detected": { + "condition": "mdi:home-outline" + } + }, + "triggers": { + "cleared": { + "trigger": "mdi:home-outline" + }, + "detected": { + "trigger": "mdi:home-account" + } + } +} diff --git a/homeassistant/components/occupancy/manifest.json b/homeassistant/components/occupancy/manifest.json new file mode 100644 index 00000000000000..db5ba9ebebe9d5 --- /dev/null +++ b/homeassistant/components/occupancy/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "occupancy", + "name": "Occupancy", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/occupancy", + "integration_type": "system", + "quality_scale": "internal" +} diff --git a/homeassistant/components/occupancy/strings.json b/homeassistant/components/occupancy/strings.json new file mode 100644 index 00000000000000..b93743b2bb8f80 --- /dev/null +++ b/homeassistant/components/occupancy/strings.json @@ -0,0 +1,68 @@ +{ + "common": { + "condition_behavior_description": "How the state should match on the targeted occupancy sensors.", + "condition_behavior_name": "Behavior", + "trigger_behavior_description": "The behavior of the targeted occupancy sensors to trigger on.", + "trigger_behavior_name": "Behavior" + }, + "conditions": { + "is_detected": { + "description": "Tests if one or more occupancy sensors are detecting occupancy.", + "fields": { + "behavior": { + "description": "[%key:component::occupancy::common::condition_behavior_description%]", + "name": "[%key:component::occupancy::common::condition_behavior_name%]" + } + }, + "name": "Occupancy is detected" + }, + "is_not_detected": { + "description": "Tests if one or more occupancy sensors are not detecting occupancy.", + "fields": { + "behavior": { + "description": "[%key:component::occupancy::common::condition_behavior_description%]", + "name": "[%key:component::occupancy::common::condition_behavior_name%]" + } + }, + "name": "Occupancy is not detected" + } + }, + "selector": { + "condition_behavior": { + "options": { + "all": "All", + "any": "Any" + } + }, + "trigger_behavior": { + "options": { + "any": "Any", + "first": "First", + "last": "Last" + } + } + }, + "title": "Occupancy", + "triggers": { + "cleared": { + "description": "Triggers after one or more occupancy sensors stop detecting occupancy.", + "fields": { + "behavior": { + "description": "[%key:component::occupancy::common::trigger_behavior_description%]", + "name": "[%key:component::occupancy::common::trigger_behavior_name%]" + } + }, + "name": "Occupancy cleared" + }, + "detected": { + "description": "Triggers after one or more occupancy sensors start detecting occupancy.", + "fields": { + "behavior": { + "description": "[%key:component::occupancy::common::trigger_behavior_description%]", + "name": "[%key:component::occupancy::common::trigger_behavior_name%]" + } + }, + "name": "Occupancy detected" + } + } +} diff --git a/homeassistant/components/occupancy/trigger.py b/homeassistant/components/occupancy/trigger.py new file mode 100644 index 00000000000000..cecac05415a41b --- /dev/null +++ b/homeassistant/components/occupancy/trigger.py @@ -0,0 +1,24 @@ +"""Provides triggers for occupancy.""" + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger + +_OCCUPANCY_DOMAIN_SPECS = { + BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.OCCUPANCY) +} + +TRIGGERS: dict[str, type[Trigger]] = { + "detected": make_entity_target_state_trigger(_OCCUPANCY_DOMAIN_SPECS, STATE_ON), + "cleared": make_entity_target_state_trigger(_OCCUPANCY_DOMAIN_SPECS, STATE_OFF), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for occupancy.""" + return TRIGGERS diff --git a/homeassistant/components/occupancy/triggers.yaml b/homeassistant/components/occupancy/triggers.yaml new file mode 100644 index 00000000000000..9613e28c4ce04a --- /dev/null +++ b/homeassistant/components/occupancy/triggers.yaml @@ -0,0 +1,25 @@ +.trigger_common_fields: &trigger_common_fields + behavior: + required: true + default: any + selector: + select: + translation_key: trigger_behavior + options: + - first + - last + - any + +detected: + fields: *trigger_common_fields + target: + entity: + - domain: binary_sensor + device_class: occupancy + +cleared: + fields: *trigger_common_fields + target: + entity: + - domain: binary_sensor + device_class: occupancy diff --git a/homeassistant/components/octoprint/binary_sensor.py b/homeassistant/components/octoprint/binary_sensor.py index a20738de1508e7..4d12ef15a4e4b5 100644 --- a/homeassistant/components/octoprint/binary_sensor.py +++ b/homeassistant/components/octoprint/binary_sensor.py @@ -56,7 +56,7 @@ def __init__( self._attr_device_info = coordinator.device_info @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if binary sensor is on.""" if not (printer := self.coordinator.data["printer"]): return None diff --git a/homeassistant/components/oem/climate.py b/homeassistant/components/oem/climate.py index a1d82ab763c290..e4bb6141191e6b 100644 --- a/homeassistant/components/oem/climate.py +++ b/homeassistant/components/oem/climate.py @@ -76,13 +76,11 @@ class ThermostatDevice(ClimateEntity): def __init__(self, thermostat, name): """Initialize the device.""" - self._name = name + self._attr_name = name self.thermostat = thermostat # set up internal state varS self._state = None - self._temperature = None - self._setpoint = None self._mode = None @property @@ -97,11 +95,6 @@ def hvac_mode(self) -> HVACMode: return HVACMode.AUTO return HVACMode.OFF - @property - def name(self): - """Return the name of this Thermostat.""" - return self._name - @property def hvac_action(self) -> HVACAction: """Return current hvac i.e. heat, cool, idle.""" @@ -111,16 +104,6 @@ def hvac_action(self) -> HVACAction: return HVACAction.HEATING return HVACAction.IDLE - @property - def current_temperature(self): - """Return the current temperature.""" - return self._temperature - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._setpoint - def set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" if hvac_mode == HVACMode.AUTO: @@ -137,7 +120,7 @@ def set_temperature(self, **kwargs: Any) -> None: def update(self) -> None: """Update local state.""" - self._setpoint = self.thermostat.setpoint - self._temperature = self.thermostat.temperature + self._attr_target_temperature = self.thermostat.setpoint + self._attr_current_temperature = self.thermostat.temperature self._state = self.thermostat.state self._mode = self.thermostat.mode diff --git a/homeassistant/components/ohmconnect/sensor.py b/homeassistant/components/ohmconnect/sensor.py index 287842178d8b46..19000da21049d9 100644 --- a/homeassistant/components/ohmconnect/sensor.py +++ b/homeassistant/components/ohmconnect/sensor.py @@ -4,6 +4,7 @@ from datetime import timedelta import logging +from typing import Any import defusedxml.ElementTree as ET import requests @@ -70,7 +71,7 @@ def native_value(self): return "Inactive" @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return {"Address": self._data.get("address"), "ID": self._ohmid} diff --git a/homeassistant/components/ohme/diagnostics.py b/homeassistant/components/ohme/diagnostics.py index a955b3b76e2cb5..fe03d335c8047d 100644 --- a/homeassistant/components/ohme/diagnostics.py +++ b/homeassistant/components/ohme/diagnostics.py @@ -19,6 +19,5 @@ async def async_get_config_entry_diagnostics( return { "device_info": client.device_info, "vehicles": client.vehicles, - "ct_connected": client.ct_connected, "cap_available": client.cap_available, } diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json index e3677b26215214..192dede3dbc07b 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.6.0"] + "requirements": ["ohme==1.7.1"] } diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index 805724b82e3973..f95f8c8881f742 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -10,9 +10,13 @@ import ollama from homeassistant.config_entries import ConfigEntry, ConfigSubentry -from homeassistant.const import CONF_URL, Platform +from homeassistant.const import CONF_API_KEY, CONF_URL, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -62,10 +66,28 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: OllamaConfigEntry) -> bool: """Set up Ollama from a config entry.""" settings = {**entry.data, **entry.options} - client = ollama.AsyncClient(host=settings[CONF_URL], verify=get_default_context()) + api_key = settings.get(CONF_API_KEY) + stripped_api_key = api_key.strip() if isinstance(api_key, str) else None + client = ollama.AsyncClient( + host=settings[CONF_URL], + headers=( + {"Authorization": f"Bearer {stripped_api_key}"} + if stripped_api_key + else None + ), + verify=get_default_context(), + ) try: async with asyncio.timeout(DEFAULT_TIMEOUT): await client.list() + except ollama.ResponseError as err: + if err.status_code in (401, 403): + raise ConfigEntryAuthFailed from err + if err.status_code >= 500 or err.status_code == 429: + raise ConfigEntryNotReady(err) from err + # If the response is a 4xx error other than 401 or 403, it likely means the URL is valid but not an Ollama instance, + # so we raise ConfigEntryError to show an error in the UI, instead of ConfigEntryNotReady which would just keep retrying. + raise ConfigEntryError(err) from err except (TimeoutError, httpx.ConnectError) as err: raise ConfigEntryNotReady(err) from err diff --git a/homeassistant/components/ollama/config_flow.py b/homeassistant/components/ollama/config_flow.py index 84f56d966f45da..5209208b9f0449 100644 --- a/homeassistant/components/ollama/config_flow.py +++ b/homeassistant/components/ollama/config_flow.py @@ -20,7 +20,7 @@ ConfigSubentryFlow, SubentryFlowResult, ) -from homeassistant.const import CONF_LLM_HASS_API, CONF_NAME, CONF_URL +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, llm from homeassistant.helpers.selector import ( @@ -68,6 +68,17 @@ vol.Required(CONF_URL): TextSelector( TextSelectorConfig(type=TextSelectorType.URL) ), + vol.Optional(CONF_API_KEY): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + }, +) + +STEP_REAUTH_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_API_KEY): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), } ) @@ -78,9 +89,40 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 3 MINOR_VERSION = 3 - def __init__(self) -> None: - """Initialize config flow.""" - self.url: str | None = None + async def _async_validate_connection( + self, url: str, api_key: str | None + ) -> dict[str, str]: + """Validate connection and credentials against the Ollama server.""" + errors: dict[str, str] = {} + + try: + client = ollama.AsyncClient( + host=url, + headers={"Authorization": f"Bearer {api_key}"} if api_key else None, + verify=get_default_context(), + ) + + async with asyncio.timeout(DEFAULT_TIMEOUT): + await client.list() + + except ollama.ResponseError as err: + if err.status_code in (401, 403): + errors["base"] = "invalid_auth" + else: + _LOGGER.warning( + "Error response from Ollama server at %s: status %s, detail: %s", + url, + err.status_code, + str(err), + ) + errors["base"] = "unknown" + except TimeoutError, httpx.ConnectError: + 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 @@ -92,9 +134,10 @@ async def async_step_user( ) errors = {} - url = user_input[CONF_URL] - - self._async_abort_entries_match({CONF_URL: url}) + url = user_input[CONF_URL].strip() + api_key = user_input.get(CONF_API_KEY) + if api_key: + api_key = api_key.strip() try: url = cv.url(url) @@ -108,15 +151,8 @@ async def async_step_user( errors=errors, ) - try: - client = ollama.AsyncClient(host=url, verify=get_default_context()) - async with asyncio.timeout(DEFAULT_TIMEOUT): - await client.list() - except TimeoutError, httpx.ConnectError: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" + self._async_abort_entries_match({CONF_URL: url}) + errors = await self._async_validate_connection(url, api_key) if errors: return self.async_show_form( @@ -127,9 +163,65 @@ async def async_step_user( errors=errors, ) - return self.async_create_entry( - title=url, - data={CONF_URL: url}, + entry_data: dict[str, str] = {CONF_URL: url} + if api_key: + entry_data[CONF_API_KEY] = api_key + + return self.async_create_entry(title=url, data=entry_data) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauthentication when existing credentials are invalid.""" + 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.""" + reauth_entry = self._get_reauth_entry() + + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_REAUTH_DATA_SCHEMA, + ) + + api_key = user_input.get(CONF_API_KEY) + if api_key: + api_key = api_key.strip() + + errors = await self._async_validate_connection( + reauth_entry.data[CONF_URL], api_key + ) + if errors: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + STEP_REAUTH_DATA_SCHEMA, user_input + ), + errors=errors, + ) + + updated_data = { + **reauth_entry.data, + CONF_URL: reauth_entry.data[CONF_URL], + } + if api_key: + updated_data[CONF_API_KEY] = api_key + else: + updated_data.pop(CONF_API_KEY, None) + + updated_options = { + key: value + for key, value in reauth_entry.options.items() + if key != CONF_API_KEY + } + + return self.async_update_reload_and_abort( + reauth_entry, + data=updated_data, + options=updated_options, ) @classmethod diff --git a/homeassistant/components/ollama/strings.json b/homeassistant/components/ollama/strings.json index f8388fb5dd00a0..b4aaa7d75e1ca0 100644 --- a/homeassistant/components/ollama/strings.json +++ b/homeassistant/components/ollama/strings.json @@ -1,16 +1,26 @@ { "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%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_url": "[%key:common::config_flow::error::invalid_host%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "description": "The Ollama integration needs to re-authenticate with your Ollama API key.", + "title": "[%key:common::config_flow::title::reauth%]" + }, "user": { "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", "url": "[%key:common::config_flow::data::url%]" } } diff --git a/homeassistant/components/omnilogic/entity.py b/homeassistant/components/omnilogic/entity.py index 6f7b769fc8fd30..99aac6995897a9 100644 --- a/homeassistant/components/omnilogic/entity.py +++ b/homeassistant/components/omnilogic/entity.py @@ -1,7 +1,5 @@ """Common classes and elements for Omnilogic Integration.""" -from typing import Any - from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -54,40 +52,14 @@ def __init__( unique_id = unique_id.replace(" ", "_") self._kind = kind - self._name = entity_friendly_name - self._unique_id = unique_id + self._attr_name = entity_friendly_name + self._attr_unique_id = unique_id self._item_id = item_id - self._icon = icon - self._attrs: dict[str, Any] = {} - self._msp_system_id = msp_system_id - self._backyard_name = coordinator.data[backyard_id]["BackyardName"] - - @property - def unique_id(self) -> str: - """Return a unique, Home Assistant friendly identifier for this entity.""" - return self._unique_id - - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._name - - @property - def icon(self): - """Return the icon for the entity.""" - return self._icon - - @property - def extra_state_attributes(self): - """Return the attributes.""" - return self._attrs - - @property - def device_info(self) -> DeviceInfo: - """Define the device as back yard/MSP System.""" - return DeviceInfo( - identifiers={(DOMAIN, self._msp_system_id)}, + self._attr_icon = icon + self._attr_extra_state_attributes = {} + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, msp_system_id)}, manufacturer="Hayward", model="OmniLogic", - name=self._backyard_name, + name=coordinator.data[backyard_id]["BackyardName"], ) diff --git a/homeassistant/components/omnilogic/sensor.py b/homeassistant/components/omnilogic/sensor.py index d941eb3ae4df49..522dcc4f3cd3da 100644 --- a/homeassistant/components/omnilogic/sensor.py +++ b/homeassistant/components/omnilogic/sensor.py @@ -115,8 +115,10 @@ def native_value(self): hayward_state = None state = None - self._attrs["hayward_temperature"] = hayward_state - self._attrs["hayward_unit_of_measure"] = hayward_unit_of_measure + self._attr_extra_state_attributes["hayward_temperature"] = hayward_state + self._attr_extra_state_attributes["hayward_unit_of_measure"] = ( + hayward_unit_of_measure + ) self._attr_native_unit_of_measurement = UnitOfTemperature.FAHRENHEIT @@ -153,7 +155,7 @@ def native_value(self): ): state = "high" - self._attrs["pump_type"] = pump_type + self._attr_extra_state_attributes["pump_type"] = pump_type return state diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index 232e8b1ad1242a..fdec23a6da25b6 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -22,6 +22,7 @@ BackupAgent, BackupAgentError, BackupNotFound, + OnProgressCallback, suggested_filename, ) from homeassistant.core import HomeAssistant, callback @@ -145,6 +146,7 @@ async def async_upload_backup( *, open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], backup: AgentBackup, + on_progress: OnProgressCallback, **kwargs: Any, ) -> None: """Upload a backup.""" @@ -178,6 +180,9 @@ async def async_upload_backup( upload_chunk_size=upload_chunk_size, session=async_get_clientsession(self._hass), smart_chunk_size=True, + progress_callback=lambda bytes_uploaded: on_progress( + bytes_uploaded=bytes_uploaded + ), ) except HashMismatchError as err: raise BackupAgentError( @@ -257,9 +262,24 @@ async def _download_metadata(item_id: str) -> AgentBackup | None: ) items = await self._client.list_drive_items(self._folder_id) + + # Build a set of backup filenames to check for orphaned metadata + backup_filenames = { + item.name for item in items if item.name and item.name.endswith(".tar") + } + metadata_files: dict[str, AgentBackup] = {} for item in items: if item.name and item.name.endswith(".metadata.json"): + # Check if corresponding backup file exists + backup_filename = f"{item.name[: -len('.metadata.json')]}.tar" + if backup_filename not in backup_filenames: + _LOGGER.warning( + "Backup file %s not found for metadata %s", + backup_filename, + item.name, + ) + continue if metadata := await _download_metadata(item.id): metadata_files[metadata.backup_id] = metadata diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json index 20cd867055f1be..367cc34076061d 100644 --- a/homeassistant/components/onedrive/manifest.json +++ b/homeassistant/components/onedrive/manifest.json @@ -10,5 +10,5 @@ "iot_class": "cloud_polling", "loggers": ["onedrive_personal_sdk"], "quality_scale": "platinum", - "requirements": ["onedrive-personal-sdk==0.1.2"] + "requirements": ["onedrive-personal-sdk==0.1.7"] } diff --git a/homeassistant/components/onedrive_for_business/__init__.py b/homeassistant/components/onedrive_for_business/__init__.py index c9aea1b60ca7f9..e2eb4b06e2cf96 100644 --- a/homeassistant/components/onedrive_for_business/__init__.py +++ b/homeassistant/components/onedrive_for_business/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Awaitable, Callable -from dataclasses import dataclass import logging from typing import cast @@ -14,12 +13,12 @@ OneDriveException, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, OAuth2Session, async_get_config_entry_implementation, ) @@ -32,19 +31,15 @@ DATA_BACKUP_AGENT_LISTENERS, DOMAIN, ) +from .coordinator import ( + OneDriveConfigEntry, + OneDriveForBusinessUpdateCoordinator, + OneDriveRuntimeData, +) -_LOGGER = logging.getLogger(__name__) - - -@dataclass -class OneDriveRuntimeData: - """Runtime data for the OneDrive integration.""" - - client: OneDriveClient - token_function: Callable[[], Awaitable[str]] - +PLATFORMS = [Platform.SENSOR] -type OneDriveConfigEntry = ConfigEntry[OneDriveRuntimeData] +_LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: @@ -68,11 +63,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> entry, data={**entry.data, CONF_FOLDER_ID: backup_folder.id} ) + coordinator = OneDriveForBusinessUpdateCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = OneDriveRuntimeData( client=client, token_function=get_access_token, + coordinator=coordinator, ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + def async_notify_backup_listeners() -> None: for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): listener() @@ -82,9 +83,9 @@ def async_notify_backup_listeners() -> None: return True -async def async_unload_entry(hass: HomeAssistant, _: OneDriveConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: """Unload a OneDrive config entry.""" - return True + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def _get_onedrive_client( @@ -92,7 +93,13 @@ async def _get_onedrive_client( ) -> tuple[OneDriveClient, Callable[[], Awaitable[str]]]: """Get OneDrive client.""" with tenant_id_context(entry.data[CONF_TENANT_ID]): - implementation = await async_get_config_entry_implementation(hass, entry) + try: + implementation = await async_get_config_entry_implementation(hass, entry) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err session = OAuth2Session(hass, entry, implementation) async def get_access_token() -> str: diff --git a/homeassistant/components/onedrive_for_business/backup.py b/homeassistant/components/onedrive_for_business/backup.py index 661b616f3cbbc0..dc35ae79743193 100644 --- a/homeassistant/components/onedrive_for_business/backup.py +++ b/homeassistant/components/onedrive_for_business/backup.py @@ -22,6 +22,7 @@ BackupAgent, BackupAgentError, BackupNotFound, + OnProgressCallback, suggested_filename, ) from homeassistant.core import HomeAssistant, callback @@ -145,6 +146,7 @@ async def async_upload_backup( *, open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], backup: AgentBackup, + on_progress: OnProgressCallback, **kwargs: Any, ) -> None: """Upload a backup.""" @@ -172,6 +174,9 @@ async def async_upload_backup( upload_chunk_size=upload_chunk_size, session=async_get_clientsession(self._hass), smart_chunk_size=True, + progress_callback=lambda bytes_uploaded: on_progress( + bytes_uploaded=bytes_uploaded + ), ) except HashMismatchError as err: raise BackupAgentError( @@ -255,7 +260,7 @@ async def _download_metadata(item_id: str) -> AgentBackup | None: for item in items: if item.name and item.name.endswith(".metadata.json"): # Check if corresponding backup file exists - backup_filename = item.name.replace(".metadata.json", ".tar") + backup_filename = f"{item.name[: -len('.metadata.json')]}.tar" if backup_filename not in backup_filenames: _LOGGER.warning( "Backup file %s not found for metadata %s", diff --git a/homeassistant/components/onedrive_for_business/config_flow.py b/homeassistant/components/onedrive_for_business/config_flow.py index ae1d9f6b681d46..c9b3c0473175ad 100644 --- a/homeassistant/components/onedrive_for_business/config_flow.py +++ b/homeassistant/components/onedrive_for_business/config_flow.py @@ -8,7 +8,7 @@ from onedrive_personal_sdk.clients.client import OneDriveClient from onedrive_personal_sdk.exceptions import OneDriveException -from onedrive_personal_sdk.models.items import AppRoot +from onedrive_personal_sdk.models.items import Drive import voluptuous as vol from homeassistant.config_entries import ( @@ -38,7 +38,7 @@ class OneDriveForBusinessConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): DOMAIN = DOMAIN client: OneDriveClient - approot: AppRoot + drive: Drive @property def logger(self) -> logging.Logger: @@ -102,8 +102,7 @@ async def get_access_token() -> str: ) try: - self.approot = await self.client.get_approot() - drive = await self.client.get_drive() + self.drive = await self.client.get_drive() except OneDriveException: self.logger.exception("Failed to connect to OneDrive") return self.async_abort(reason="connection_error") @@ -111,7 +110,7 @@ async def get_access_token() -> str: self.logger.exception("Unknown error") return self.async_abort(reason="unknown") - await self.async_set_unique_id(drive.id) + await self.async_set_unique_id(self.drive.id) if self.source == SOURCE_REAUTH: self._abort_if_unique_id_mismatch(reason="wrong_drive") @@ -147,9 +146,11 @@ async def async_step_select_folder( errors["base"] = "folder_creation_error" if not errors: title = ( - f"{self.approot.created_by.user.display_name}'s OneDrive" - if self.approot.created_by.user - and self.approot.created_by.user.display_name + f"{self.drive.owner.user.display_name}'s OneDrive ({self.drive.owner.user.email})" + if self.drive.owner + and self.drive.owner.user + and self.drive.owner.user.display_name + and self.drive.owner.user.email else "OneDrive" ) return self.async_create_entry( diff --git a/homeassistant/components/onedrive_for_business/coordinator.py b/homeassistant/components/onedrive_for_business/coordinator.py new file mode 100644 index 00000000000000..ee5abb965282d0 --- /dev/null +++ b/homeassistant/components/onedrive_for_business/coordinator.py @@ -0,0 +1,102 @@ +"""Coordinator for OneDrive for Business.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from datetime import timedelta +import logging +from time import time + +from onedrive_personal_sdk import OneDriveClient +from onedrive_personal_sdk.const import DriveState +from onedrive_personal_sdk.exceptions import AuthenticationError, OneDriveException +from onedrive_personal_sdk.models.items import Drive + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +SCAN_INTERVAL = timedelta(minutes=5) + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class OneDriveRuntimeData: + """Runtime data for the OneDrive integration.""" + + client: OneDriveClient + token_function: Callable[[], Awaitable[str]] + coordinator: OneDriveForBusinessUpdateCoordinator + + +type OneDriveConfigEntry = ConfigEntry[OneDriveRuntimeData] + + +class OneDriveForBusinessUpdateCoordinator(DataUpdateCoordinator[Drive]): + """Class to handle fetching data from the Graph API centrally.""" + + config_entry: OneDriveConfigEntry + + def __init__( + self, hass: HomeAssistant, entry: OneDriveConfigEntry, client: OneDriveClient + ) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self._client = client + + async def _async_update_data(self) -> Drive: + """Fetch data from API endpoint.""" + expires_at = self.config_entry.data["token"]["expires_at"] + _LOGGER.debug( + "Token expiry: %s (in %s seconds)", + expires_at, + expires_at - time(), + ) + + try: + drive = await self._client.get_drive() + except AuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from err + except OneDriveException as err: + _LOGGER.debug("Failed to fetch drive data: %s", err, exc_info=True) + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="update_failed" + ) from err + + # create an issue if the drive is almost full + if drive.quota and (state := drive.quota.state) in ( + DriveState.CRITICAL, + DriveState.EXCEEDED, + ): + key = "drive_full" if state is DriveState.EXCEEDED else "drive_almost_full" + ir.async_create_issue( + self.hass, + DOMAIN, + key, + is_fixable=False, + severity=( + ir.IssueSeverity.ERROR + if state is DriveState.EXCEEDED + else ir.IssueSeverity.WARNING + ), + translation_key=key, + translation_placeholders={ + "total": f"{drive.quota.total / (1024**3):.2f}", + "used": f"{drive.quota.used / (1024**3):.2f}", + }, + ) + return drive diff --git a/homeassistant/components/onedrive_for_business/diagnostics.py b/homeassistant/components/onedrive_for_business/diagnostics.py new file mode 100644 index 00000000000000..404cb3b507de0b --- /dev/null +++ b/homeassistant/components/onedrive_for_business/diagnostics.py @@ -0,0 +1,33 @@ +"""Diagnostics support for OneDrive for Business.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.core import HomeAssistant + +from .coordinator import OneDriveConfigEntry + +TO_REDACT = {"display_name", "email", CONF_ACCESS_TOKEN, CONF_TOKEN} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: OneDriveConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + coordinator = entry.runtime_data.coordinator + + data = { + "drive": asdict(coordinator.data), + "config": { + **entry.data, + **entry.options, + }, + } + + return async_redact_data(data, TO_REDACT) diff --git a/homeassistant/components/onedrive_for_business/icons.json b/homeassistant/components/onedrive_for_business/icons.json new file mode 100644 index 00000000000000..62396439fd12af --- /dev/null +++ b/homeassistant/components/onedrive_for_business/icons.json @@ -0,0 +1,24 @@ +{ + "entity": { + "sensor": { + "drive_state": { + "default": "mdi:harddisk", + "state": { + "critical": "mdi:alert", + "exceeded": "mdi:alert-octagon", + "nearing": "mdi:alert-circle-outline", + "normal": "mdi:harddisk" + } + }, + "remaining_size": { + "default": "mdi:database" + }, + "total_size": { + "default": "mdi:database" + }, + "used_size": { + "default": "mdi:database" + } + } + } +} diff --git a/homeassistant/components/onedrive_for_business/manifest.json b/homeassistant/components/onedrive_for_business/manifest.json index e398cefa12ade3..6397b2e25e885b 100644 --- a/homeassistant/components/onedrive_for_business/manifest.json +++ b/homeassistant/components/onedrive_for_business/manifest.json @@ -9,6 +9,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["onedrive_personal_sdk"], - "quality_scale": "bronze", - "requirements": ["onedrive-personal-sdk==0.1.2"] + "quality_scale": "platinum", + "requirements": ["onedrive-personal-sdk==0.1.7"] } diff --git a/homeassistant/components/onedrive_for_business/quality_scale.yaml b/homeassistant/components/onedrive_for_business/quality_scale.yaml index 91917eb4af0009..566b65e0311dc1 100644 --- a/homeassistant/components/onedrive_for_business/quality_scale.yaml +++ b/homeassistant/components/onedrive_for_business/quality_scale.yaml @@ -21,14 +21,8 @@ rules: status: exempt comment: | Entities of this integration does not explicitly subscribe to events. - entity-unique-id: - status: exempt - comment: | - This integration does not create entities. - has-entity-name: - status: exempt - comment: | - This integration does not create entities. + entity-unique-id: done + has-entity-name: done runtime-data: done test-before-configure: done test-before-setup: done @@ -42,28 +36,16 @@ rules: comment: | This integration does not have configuration parameters. docs-installation-parameters: done - entity-unavailable: - status: exempt - comment: | - This integration does not create entities. + entity-unavailable: done integration-owner: done - log-when-unavailable: - status: exempt - comment: | - This integration does not create entities. - parallel-updates: - status: exempt - comment: | - This integration does not create entities. + log-when-unavailable: done + parallel-updates: done reauthentication-flow: done test-coverage: done # Gold - devices: - status: exempt - comment: | - This integration does not create entities. - diagnostics: todo + devices: done + diagnostics: done discovery-update-info: status: exempt comment: | @@ -72,10 +54,7 @@ rules: status: exempt comment: | This integration is a cloud service and does not support discovery. - docs-data-update: - status: exempt - comment: | - This integration does not create entities. + docs-data-update: done docs-examples: status: exempt comment: | @@ -95,32 +74,14 @@ rules: status: exempt comment: | This integration connects to a single service. - entity-category: - status: exempt - comment: | - This integration does not create entities. - entity-device-class: - status: exempt - comment: | - This integration does not create entities. - entity-disabled-by-default: - status: exempt - comment: | - This integration does not create entities. - entity-translations: - status: exempt - comment: | - This integration does not create entities. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done exception-translations: done - icon-translations: - status: exempt - comment: | - This integration does not create entities. + icon-translations: done reconfiguration-flow: done - repair-issues: - status: exempt - comment: | - No repairs yet. + repair-issues: done stale-devices: status: exempt comment: | diff --git a/homeassistant/components/onedrive_for_business/sensor.py b/homeassistant/components/onedrive_for_business/sensor.py new file mode 100644 index 00000000000000..a717cd490c0adc --- /dev/null +++ b/homeassistant/components/onedrive_for_business/sensor.py @@ -0,0 +1,123 @@ +"""Sensors for OneDrive for Business.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from onedrive_personal_sdk.const import DriveState +from onedrive_personal_sdk.models.items import DriveQuota + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import EntityCategory, UnitOfInformation +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 .const import DOMAIN +from .coordinator import OneDriveConfigEntry, OneDriveForBusinessUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +@dataclass(kw_only=True, frozen=True) +class OneDriveForBusinessSensorEntityDescription(SensorEntityDescription): + """Describes OneDrive sensor entity.""" + + value_fn: Callable[[DriveQuota], StateType] + + +DRIVE_STATE_ENTITIES: tuple[OneDriveForBusinessSensorEntityDescription, ...] = ( + OneDriveForBusinessSensorEntityDescription( + key="total_size", + value_fn=lambda quota: quota.total, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=0, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + OneDriveForBusinessSensorEntityDescription( + key="used_size", + value_fn=lambda quota: quota.used, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=2, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + OneDriveForBusinessSensorEntityDescription( + key="remaining_size", + value_fn=lambda quota: quota.remaining, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=2, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + OneDriveForBusinessSensorEntityDescription( + key="drive_state", + value_fn=lambda quota: quota.state.value, + options=[state.value for state in DriveState], + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OneDriveConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up OneDrive for Business sensors based on a config entry.""" + coordinator = entry.runtime_data.coordinator + async_add_entities( + OneDriveDriveStateSensor(coordinator, description) + for description in DRIVE_STATE_ENTITIES + ) + + +class OneDriveDriveStateSensor( + CoordinatorEntity[OneDriveForBusinessUpdateCoordinator], SensorEntity +): + """Define a OneDrive for Business sensor.""" + + entity_description: OneDriveForBusinessSensorEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: OneDriveForBusinessUpdateCoordinator, + description: OneDriveForBusinessSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_translation_key = description.key + self._attr_unique_id = f"{coordinator.data.id}_{description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + name=coordinator.data.name or coordinator.config_entry.title, + identifiers={(DOMAIN, coordinator.data.id)}, + manufacturer="Microsoft", + model=f"OneDrive {coordinator.data.drive_type.value.capitalize()}", + ) + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + if TYPE_CHECKING: + assert self.coordinator.data.quota + return self.entity_description.value_fn(self.coordinator.data.quota) + + @property + def available(self) -> bool: + """Availability of the sensor.""" + return super().available and self.coordinator.data.quota is not None diff --git a/homeassistant/components/onedrive_for_business/strings.json b/homeassistant/components/onedrive_for_business/strings.json index fa151ea1b85c18..dce713a746239e 100644 --- a/homeassistant/components/onedrive_for_business/strings.json +++ b/homeassistant/components/onedrive_for_business/strings.json @@ -9,6 +9,7 @@ "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_implementation_unavailable": "[%key:common::config_flow::abort::oauth2_implementation_unavailable%]", "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%]", @@ -69,12 +70,50 @@ } } }, + "entity": { + "sensor": { + "drive_state": { + "name": "[%key:component::onedrive::entity::sensor::drive_state::name%]", + "state": { + "critical": "[%key:component::onedrive::entity::sensor::drive_state::state::critical%]", + "exceeded": "[%key:component::onedrive::entity::sensor::drive_state::state::exceeded%]", + "nearing": "[%key:component::onedrive::entity::sensor::drive_state::state::nearing%]", + "normal": "[%key:common::state::normal%]" + } + }, + "remaining_size": { + "name": "[%key:component::onedrive::entity::sensor::remaining_size::name%]" + }, + "total_size": { + "name": "[%key:component::onedrive::entity::sensor::total_size::name%]" + }, + "used_size": { + "name": "[%key:component::onedrive::entity::sensor::used_size::name%]" + } + } + }, "exceptions": { "authentication_failed": { "message": "[%key:component::onedrive::exceptions::authentication_failed::message%]" }, "failed_to_get_folder": { "message": "[%key:component::onedrive::exceptions::failed_to_get_folder::message%]" + }, + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + }, + "update_failed": { + "message": "[%key:component::onedrive::exceptions::update_failed::message%]" + } + }, + "issues": { + "drive_almost_full": { + "description": "[%key:component::onedrive::issues::drive_almost_full::description%]", + "title": "[%key:component::onedrive::issues::drive_almost_full::title%]" + }, + "drive_full": { + "description": "[%key:component::onedrive::issues::drive_full::description%]", + "title": "[%key:component::onedrive::issues::drive_full::title%]" } } } diff --git a/homeassistant/components/onkyo/__init__.py b/homeassistant/components/onkyo/__init__.py index df09189646d8cb..ed2bb2904cd0ca 100644 --- a/homeassistant/components/onkyo/__init__.py +++ b/homeassistant/components/onkyo/__init__.py @@ -17,12 +17,13 @@ InputSource, ListeningMode, ) +from .coordinator import ChannelMutingCoordinator from .receiver import ReceiverManager, async_interview from .services import async_setup_services _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.MEDIA_PLAYER] +PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SWITCH] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -66,6 +67,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> boo entry.runtime_data = OnkyoData(manager, sources, sound_modes) + ChannelMutingCoordinator(hass, entry, manager) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) if error := await manager.start(): diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py index f317eafec098bc..f4a85f56acb0cf 100644 --- a/homeassistant/components/onkyo/config_flow.py +++ b/homeassistant/components/onkyo/config_flow.py @@ -213,7 +213,7 @@ async def async_step_ssdp( try: info = await async_interview(host) except TimeoutError: - _LOGGER.warning("Timed out interviewing: %s", host) + _LOGGER.info("Timed out interviewing: %s", host) return self.async_abort(reason="cannot_connect") except OSError: _LOGGER.exception("Unexpected exception interviewing: %s", host) diff --git a/homeassistant/components/onkyo/coordinator.py b/homeassistant/components/onkyo/coordinator.py new file mode 100644 index 00000000000000..d418b09ad04b83 --- /dev/null +++ b/homeassistant/components/onkyo/coordinator.py @@ -0,0 +1,167 @@ +"""Onkyo coordinators.""" + +from __future__ import annotations + +import asyncio +from enum import StrEnum +import logging +from typing import TYPE_CHECKING, cast + +from aioonkyo import Kind, Status, Zone, command, query, status + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN +from .receiver import ReceiverManager + +if TYPE_CHECKING: + from . import OnkyoConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +POWER_ON_QUERY_DELAY = 4 + + +class Channel(StrEnum): + """Audio channel.""" + + FRONT_LEFT = "front_left" + FRONT_RIGHT = "front_right" + CENTER = "center" + SURROUND_LEFT = "surround_left" + SURROUND_RIGHT = "surround_right" + SURROUND_BACK_LEFT = "surround_back_left" + SURROUND_BACK_RIGHT = "surround_back_right" + SUBWOOFER = "subwoofer" + HEIGHT_1_LEFT = "height_1_left" + HEIGHT_1_RIGHT = "height_1_right" + HEIGHT_2_LEFT = "height_2_left" + HEIGHT_2_RIGHT = "height_2_right" + SUBWOOFER_2 = "subwoofer_2" + + +ChannelMutingData = dict[Channel, status.ChannelMuting.Param] +ChannelMutingDesired = dict[Channel, command.ChannelMuting.Param] + + +class ChannelMutingCoordinator(DataUpdateCoordinator[ChannelMutingData]): + """Coordinator for channel muting state.""" + + config_entry: OnkyoConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: OnkyoConfigEntry, + manager: ReceiverManager, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name="onkyo_channel_muting", + update_interval=None, + ) + + self.manager = manager + + self.data = ChannelMutingData() + self._desired = ChannelMutingDesired() + + self._entities_added = False + + self._query_state_task: asyncio.Task[None] | None = None + + manager.callbacks.connect.append(self._connect_callback) + manager.callbacks.disconnect.append(self._disconnect_callback) + manager.callbacks.update.append(self._update_callback) + + config_entry.async_on_unload(self._cancel_tasks) + + async def _connect_callback(self, _reconnect: bool) -> None: + """Receiver (re)connected.""" + await self.manager.write(query.ChannelMuting()) + + async def _disconnect_callback(self) -> None: + """Receiver disconnected.""" + self._cancel_tasks() + self.async_set_updated_data(self.data) + + def _cancel_tasks(self) -> None: + """Cancel the tasks.""" + if self._query_state_task is not None: + self._query_state_task.cancel() + self._query_state_task = None + + def _query_state(self, delay: float = 0) -> None: + """Query the receiver for all the info, that we care about.""" + if self._query_state_task is not None: + self._query_state_task.cancel() + self._query_state_task = None + + async def coro() -> None: + if delay: + await asyncio.sleep(delay) + await self.manager.write(query.ChannelMuting()) + self._query_state_task = None + + self._query_state_task = asyncio.create_task(coro()) + + async def _async_update_data(self) -> ChannelMutingData: + """Respond to a data update request.""" + self._query_state() + return self.data + + async def async_send_command( + self, channel: Channel, param: command.ChannelMuting.Param + ) -> None: + """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] + await self.manager.write(message) + + async def _update_callback(self, message: Status) -> None: + """New message from the receiver.""" + match message: + case status.NotAvailable(kind=Kind.CHANNEL_MUTING): + not_available = True + case status.ChannelMuting(): + not_available = False + case status.Power(zone=Zone.MAIN, param=status.Power.Param.ON): + self._query_state(POWER_ON_QUERY_DELAY) + return + case _: + return + + if not self._entities_added: + _LOGGER.debug( + "Discovered %s on %s (%s)", + self.name, + self.manager.info.model_name, + self.manager.info.host, + ) + self._entities_added = True + async_dispatcher_send( + self.hass, + f"{DOMAIN}_{self.config_entry.entry_id}_channel_muting", + self, + ) + + if not_available: + self.data.clear() + self._desired.clear() + self.async_set_updated_data(self.data) + else: + message = cast(status.ChannelMuting, message) + self.data = {channel: getattr(message, channel) for channel in Channel} + self._desired = { + channel: desired + for channel, desired in self._desired.items() + if self.data[channel] != desired + } + self.async_set_updated_data(self.data) diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index 37065fd5aecfca..e69c9ef05434c1 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -100,7 +100,7 @@ async def async_setup_entry( entry: OnkyoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up MediaPlayer for config entry.""" + """Set up media player platform for config entry.""" data = entry.runtime_data manager = data.manager diff --git a/homeassistant/components/onkyo/switch.py b/homeassistant/components/onkyo/switch.py new file mode 100644 index 00000000000000..f60c1c1ddcb569 --- /dev/null +++ b/homeassistant/components/onkyo/switch.py @@ -0,0 +1,96 @@ +"""Switch platform.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from aioonkyo import command, status + +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import Channel, ChannelMutingCoordinator + +if TYPE_CHECKING: + from . import OnkyoConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OnkyoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up switch platform for config entry.""" + + @callback + def async_add_channel_muting_entities( + coordinator: ChannelMutingCoordinator, + ) -> None: + """Add channel muting switch entities.""" + async_add_entities( + OnkyoChannelMutingSwitch(coordinator, channel) for channel in Channel + ) + + entry.async_on_unload( + async_dispatcher_connect( + hass, + f"{DOMAIN}_{entry.entry_id}_channel_muting", + async_add_channel_muting_entities, + ) + ) + + +class OnkyoChannelMutingSwitch( + CoordinatorEntity[ChannelMutingCoordinator], SwitchEntity +): + """Onkyo Receiver Channel Muting Switch (one per channel).""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: ChannelMutingCoordinator, + channel: Channel, + ) -> None: + """Initialize the switch entity.""" + super().__init__(coordinator) + + self._channel = channel + + name = coordinator.manager.info.model_name + channel_name = channel.replace("_", " ") + identifier = coordinator.manager.info.identifier + self._attr_name = f"{name} Mute {channel_name}" + self._attr_unique_id = f"{identifier}-channel_muting-{channel}" + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.manager.connected + + async def async_turn_on(self, **kwargs: Any) -> None: + """Mute the channel.""" + await self.coordinator.async_send_command( + self._channel, command.ChannelMuting.Param.ON + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Unmute the channel.""" + await self.coordinator.async_send_command( + self._channel, command.ChannelMuting.Param.OFF + ) + + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + value = self.coordinator.data.get(self._channel) + self._attr_is_on = ( + None if value is None else value == status.ChannelMuting.Param.ON + ) + super()._handle_coordinator_update() diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index 86ec419f8921bc..4770f8828b81c5 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -16,23 +16,31 @@ ) from onvif.exceptions import ONVIFError from onvif.util import stringify_onvif_error +import onvif_parsers +import onvif_parsers.util from zeep.exceptions import Fault, TransportError, ValidationError, XMLParseError from homeassistant.components import webhook from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import NoURLAvailableError, get_url +from homeassistant.util import dt as dt_util from .const import DOMAIN, LOGGER from .models import Event, PullPointManagerState, WebHookManagerState -from .parsers import PARSERS # Topics in this list are ignored because we do not want to create # entities for them. UNHANDLED_TOPICS: set[str] = {"tns1:MediaControl/VideoEncoderConfiguration"} +ENTITY_CATEGORY_MAPPING: dict[str, EntityCategory] = { + "diagnostic": EntityCategory.DIAGNOSTIC, + "config": EntityCategory.CONFIG, +} + SUBSCRIPTION_ERRORS = (Fault, TimeoutError, TransportError) CREATE_ERRORS = ( ONVIFError, @@ -81,6 +89,18 @@ PULLPOINT_COOLDOWN_TIME = 0.75 +def _local_datetime_or_none(value: str) -> dt.datetime | None: + """Convert strings to datetimes, if invalid, return None.""" + # Handle cameras that return times like '0000-00-00T00:00:00Z' (e.g. Hikvision) + try: + ret = dt_util.parse_datetime(value) + except ValueError: + return None + if ret is not None: + return dt_util.as_local(ret) + return None + + class EventManager: """ONVIF Event Manager.""" @@ -176,36 +196,52 @@ async def async_parse_messages(self, messages) -> None: # tns1:RuleEngine/CellMotionDetector/Motion topic = msg.Topic._value_1.rstrip("/.") # noqa: SLF001 - if not (parser := PARSERS.get(topic)): + try: + events = await onvif_parsers.parse(topic, unique_id, msg) + error = None + except onvif_parsers.errors.UnknownTopicError: if topic not in UNHANDLED_TOPICS: LOGGER.warning( "%s: No registered handler for event from %s: %s", self.name, unique_id, - msg, + onvif_parsers.util.event_to_debug_format(msg), ) UNHANDLED_TOPICS.add(topic) continue - - try: - event = await parser(unique_id, msg) - error = None except (AttributeError, KeyError) as e: - event = None + events = [] error = e - if not event: + if not events: LOGGER.warning( "%s: Unable to parse event from %s: %s: %s", self.name, unique_id, error, - msg, + onvif_parsers.util.event_to_debug_format(msg), ) - return + continue - self.get_uids_by_platform(event.platform).add(event.uid) - self._events[event.uid] = event + for event in events: + value = event.value + if event.device_class == "timestamp" and isinstance(value, str): + value = _local_datetime_or_none(value) + + ha_event = Event( + uid=event.uid, + name=event.name, + platform=event.platform, + device_class=event.device_class, + unit_of_measurement=event.unit_of_measurement, + value=value, + entity_category=ENTITY_CATEGORY_MAPPING.get( + event.entity_category or "" + ), + entity_enabled=event.entity_enabled, + ) + self.get_uids_by_platform(ha_event.platform).add(ha_event.uid) + self._events[ha_event.uid] = ha_event def get_uid(self, uid: str) -> Event | None: """Retrieve event for given id.""" diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index e35addf52fe332..9d15ca0afe60f5 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -13,5 +13,9 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==4.0.4", "WSDiscovery==2.1.2"] + "requirements": [ + "onvif-zeep-async==4.0.4", + "onvif_parsers==2.3.0", + "WSDiscovery==2.1.2" + ] } diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py deleted file mode 100644 index 32adf696bde25d..00000000000000 --- a/homeassistant/components/onvif/parsers.py +++ /dev/null @@ -1,755 +0,0 @@ -"""ONVIF event parsers.""" - -from __future__ import annotations - -from collections.abc import Callable, Coroutine -import dataclasses -import datetime -from typing import Any - -from homeassistant.const import EntityCategory -from homeassistant.util import dt as dt_util -from homeassistant.util.decorator import Registry - -from .models import Event - -PARSERS: Registry[str, Callable[[str, Any], Coroutine[Any, Any, Event | None]]] = ( - Registry() -) - -VIDEO_SOURCE_MAPPING = { - "vsconf": "VideoSourceToken", -} - - -def extract_message(msg: Any) -> tuple[str, Any]: - """Extract the message content and the topic.""" - return msg.Topic._value_1, msg.Message._value_1 # noqa: SLF001 - - -def _normalize_video_source(source: str) -> str: - """Normalize video source. - - Some cameras do not set the VideoSourceToken correctly so we get duplicate - sensors, so we need to normalize it to the correct value. - """ - return VIDEO_SOURCE_MAPPING.get(source, source) - - -def local_datetime_or_none(value: str) -> datetime.datetime | None: - """Convert strings to datetimes, if invalid, return None.""" - # To handle cameras that return times like '0000-00-00T00:00:00Z' (e.g. hikvision) - try: - ret = dt_util.parse_datetime(value) - except ValueError: - return None - if ret is not None: - return dt_util.as_local(ret) - return None - - -@PARSERS.register("tns1:VideoSource/MotionAlarm") -@PARSERS.register("tns1:Device/Trigger/tnshik:AlarmIn") -async def async_parse_motion_alarm(uid: str, msg) -> Event | None: - """Handle parsing event message. - - Topic: tns1:VideoSource/MotionAlarm - """ - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Motion Alarm", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - - -@PARSERS.register("tns1:VideoSource/ImageTooBlurry/AnalyticsService") -@PARSERS.register("tns1:VideoSource/ImageTooBlurry/ImagingService") -@PARSERS.register("tns1:VideoSource/ImageTooBlurry/RecordingService") -async def async_parse_image_too_blurry(uid: str, msg) -> Event | None: - """Handle parsing event message. - - Topic: tns1:VideoSource/ImageTooBlurry/* - """ - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Image Too Blurry", - "binary_sensor", - "problem", - None, - payload.Data.SimpleItem[0].Value == "true", - EntityCategory.DIAGNOSTIC, - ) - - -@PARSERS.register("tns1:VideoSource/ImageTooDark/AnalyticsService") -@PARSERS.register("tns1:VideoSource/ImageTooDark/ImagingService") -@PARSERS.register("tns1:VideoSource/ImageTooDark/RecordingService") -async def async_parse_image_too_dark(uid: str, msg) -> Event | None: - """Handle parsing event message. - - Topic: tns1:VideoSource/ImageTooDark/* - """ - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Image Too Dark", - "binary_sensor", - "problem", - None, - payload.Data.SimpleItem[0].Value == "true", - EntityCategory.DIAGNOSTIC, - ) - - -@PARSERS.register("tns1:VideoSource/ImageTooBright/AnalyticsService") -@PARSERS.register("tns1:VideoSource/ImageTooBright/ImagingService") -@PARSERS.register("tns1:VideoSource/ImageTooBright/RecordingService") -async def async_parse_image_too_bright(uid: str, msg) -> Event | None: - """Handle parsing event message. - - Topic: tns1:VideoSource/ImageTooBright/* - """ - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Image Too Bright", - "binary_sensor", - "problem", - None, - payload.Data.SimpleItem[0].Value == "true", - EntityCategory.DIAGNOSTIC, - ) - - -@PARSERS.register("tns1:VideoSource/GlobalSceneChange/AnalyticsService") -@PARSERS.register("tns1:VideoSource/GlobalSceneChange/ImagingService") -@PARSERS.register("tns1:VideoSource/GlobalSceneChange/RecordingService") -async def async_parse_scene_change(uid: str, msg) -> Event | None: - """Handle parsing event message. - - Topic: tns1:VideoSource/GlobalSceneChange/* - """ - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Global Scene Change", - "binary_sensor", - "problem", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - - -@PARSERS.register("tns1:AudioAnalytics/Audio/DetectedSound") -async def async_parse_detected_sound(uid: str, msg) -> Event | None: - """Handle parsing event message. - - Topic: tns1:AudioAnalytics/Audio/DetectedSound - """ - audio_source = "" - audio_analytics = "" - rule = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "AudioSourceConfigurationToken": - audio_source = source.Value - if source.Name == "AudioAnalyticsConfigurationToken": - audio_analytics = source.Value - if source.Name == "Rule": - rule = source.Value - - return Event( - f"{uid}_{topic}_{audio_source}_{audio_analytics}_{rule}", - "Detected Sound", - "binary_sensor", - "sound", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - - -@PARSERS.register("tns1:RuleEngine/FieldDetector/ObjectsInside") -async def async_parse_field_detector(uid: str, msg) -> Event | None: - """Handle parsing event message. - - Topic: tns1:RuleEngine/FieldDetector/ObjectsInside - """ - video_source = "" - video_analytics = "" - rule = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "VideoSourceConfigurationToken": - video_source = _normalize_video_source(source.Value) - if source.Name == "VideoAnalyticsConfigurationToken": - video_analytics = source.Value - if source.Name == "Rule": - rule = source.Value - - return Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Field Detection", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - - -@PARSERS.register("tns1:RuleEngine/CellMotionDetector/Motion") -async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None: - """Handle parsing event message. - - Topic: tns1:RuleEngine/CellMotionDetector/Motion - """ - video_source = "" - video_analytics = "" - rule = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "VideoSourceConfigurationToken": - video_source = _normalize_video_source(source.Value) - if source.Name == "VideoAnalyticsConfigurationToken": - video_analytics = source.Value - if source.Name == "Rule": - rule = source.Value - - return Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Cell Motion Detection", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - - -@PARSERS.register("tns1:RuleEngine/MotionRegionDetector/Motion") -async def async_parse_motion_region_detector(uid: str, msg) -> Event | None: - """Handle parsing event message. - - Topic: tns1:RuleEngine/MotionRegionDetector/Motion - """ - video_source = "" - video_analytics = "" - rule = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "VideoSourceConfigurationToken": - video_source = _normalize_video_source(source.Value) - if source.Name == "VideoAnalyticsConfigurationToken": - video_analytics = source.Value - if source.Name == "Rule": - rule = source.Value - - return Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Motion Region Detection", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value in ["1", "true"], - ) - - -@PARSERS.register("tns1:RuleEngine/TamperDetector/Tamper") -async def async_parse_tamper_detector(uid: str, msg) -> Event | None: - """Handle parsing event message. - - Topic: tns1:RuleEngine/TamperDetector/Tamper - """ - video_source = "" - video_analytics = "" - rule = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "VideoSourceConfigurationToken": - video_source = _normalize_video_source(source.Value) - if source.Name == "VideoAnalyticsConfigurationToken": - video_analytics = source.Value - if source.Name == "Rule": - rule = source.Value - - return Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Tamper Detection", - "binary_sensor", - "problem", - None, - payload.Data.SimpleItem[0].Value == "true", - EntityCategory.DIAGNOSTIC, - ) - - -@PARSERS.register("tns1:RuleEngine/MyRuleDetector/DogCatDetect") -async def async_parse_dog_cat_detector(uid: str, msg) -> Event | None: - """Handle parsing event message. - - Topic: tns1:RuleEngine/MyRuleDetector/DogCatDetect - """ - video_source = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "Source": - video_source = _normalize_video_source(source.Value) - - return Event( - f"{uid}_{topic}_{video_source}", - "Pet Detection", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - - -@PARSERS.register("tns1:RuleEngine/MyRuleDetector/VehicleDetect") -async def async_parse_vehicle_detector(uid: str, msg) -> Event | None: - """Handle parsing event message. - - Topic: tns1:RuleEngine/MyRuleDetector/VehicleDetect - """ - video_source = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "Source": - video_source = _normalize_video_source(source.Value) - - return Event( - f"{uid}_{topic}_{video_source}", - "Vehicle Detection", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - - -_TAPO_EVENT_TEMPLATES: dict[str, Event] = { - "IsVehicle": Event( - uid="", - name="Vehicle Detection", - platform="binary_sensor", - device_class="motion", - ), - "IsPeople": Event( - uid="", name="Person Detection", platform="binary_sensor", device_class="motion" - ), - "IsPet": Event( - uid="", name="Pet Detection", platform="binary_sensor", device_class="motion" - ), - "IsLineCross": Event( - uid="", - name="Line Detector Crossed", - platform="binary_sensor", - device_class="motion", - ), - "IsTamper": Event( - uid="", name="Tamper Detection", platform="binary_sensor", device_class="tamper" - ), - "IsIntrusion": Event( - uid="", - name="Intrusion Detection", - platform="binary_sensor", - device_class="safety", - ), -} - - -@PARSERS.register("tns1:RuleEngine/CellMotionDetector/Intrusion") -@PARSERS.register("tns1:RuleEngine/CellMotionDetector/LineCross") -@PARSERS.register("tns1:RuleEngine/CellMotionDetector/People") -@PARSERS.register("tns1:RuleEngine/CellMotionDetector/Tamper") -@PARSERS.register("tns1:RuleEngine/CellMotionDetector/TpSmartEvent") -@PARSERS.register("tns1:RuleEngine/PeopleDetector/People") -@PARSERS.register("tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent") -async def async_parse_tplink_detector(uid: str, msg) -> Event | None: - """Handle parsing tplink smart event messages. - - Topic: tns1:RuleEngine/CellMotionDetector/Intrusion - Topic: tns1:RuleEngine/CellMotionDetector/LineCross - Topic: tns1:RuleEngine/CellMotionDetector/People - Topic: tns1:RuleEngine/CellMotionDetector/Tamper - Topic: tns1:RuleEngine/CellMotionDetector/TpSmartEvent - Topic: tns1:RuleEngine/PeopleDetector/People - Topic: tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent - """ - video_source = "" - video_analytics = "" - rule = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "VideoSourceConfigurationToken": - video_source = _normalize_video_source(source.Value) - if source.Name == "VideoAnalyticsConfigurationToken": - video_analytics = source.Value - if source.Name == "Rule": - rule = source.Value - - for item in payload.Data.SimpleItem: - event_template = _TAPO_EVENT_TEMPLATES.get(item.Name) - if event_template is None: - continue - - return dataclasses.replace( - event_template, - uid=f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - value=item.Value == "true", - ) - - return None - - -@PARSERS.register("tns1:RuleEngine/MyRuleDetector/PeopleDetect") -async def async_parse_person_detector(uid: str, msg) -> Event | None: - """Handle parsing event message. - - Topic: tns1:RuleEngine/MyRuleDetector/PeopleDetect - """ - video_source = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "Source": - video_source = _normalize_video_source(source.Value) - - return Event( - f"{uid}_{topic}_{video_source}", - "Person Detection", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - - -@PARSERS.register("tns1:RuleEngine/MyRuleDetector/FaceDetect") -async def async_parse_face_detector(uid: str, msg) -> Event | None: - """Handle parsing event message. - - Topic: tns1:RuleEngine/MyRuleDetector/FaceDetect - """ - video_source = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "Source": - video_source = _normalize_video_source(source.Value) - - return Event( - f"{uid}_{topic}_{video_source}", - "Face Detection", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - - -@PARSERS.register("tns1:RuleEngine/MyRuleDetector/Visitor") -async def async_parse_visitor_detector(uid: str, msg) -> Event | None: - """Handle parsing event message. - - Topic: tns1:RuleEngine/MyRuleDetector/Visitor - """ - video_source = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "Source": - video_source = _normalize_video_source(source.Value) - - return Event( - f"{uid}_{topic}_{video_source}", - "Visitor Detection", - "binary_sensor", - "occupancy", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - - -@PARSERS.register("tns1:RuleEngine/MyRuleDetector/Package") -async def async_parse_package_detector(uid: str, msg) -> Event | None: - """Handle parsing event message. - - Topic: tns1:RuleEngine/MyRuleDetector/Package - """ - video_source = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "Source": - video_source = _normalize_video_source(source.Value) - - return Event( - f"{uid}_{topic}_{video_source}", - "Package Detection", - "binary_sensor", - "occupancy", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - - -@PARSERS.register("tns1:Device/Trigger/DigitalInput") -async def async_parse_digital_input(uid: str, msg) -> Event | None: - """Handle parsing event message. - - Topic: tns1:Device/Trigger/DigitalInput - """ - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Digital Input", - "binary_sensor", - None, - None, - payload.Data.SimpleItem[0].Value == "true", - ) - - -@PARSERS.register("tns1:Device/Trigger/Relay") -async def async_parse_relay(uid: str, msg) -> Event | None: - """Handle parsing event message. - - Topic: tns1:Device/Trigger/Relay - """ - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Relay Triggered", - "binary_sensor", - None, - None, - payload.Data.SimpleItem[0].Value == "active", - ) - - -@PARSERS.register("tns1:Device/HardwareFailure/StorageFailure") -async def async_parse_storage_failure(uid: str, msg) -> Event | None: - """Handle parsing event message. - - Topic: tns1:Device/HardwareFailure/StorageFailure - """ - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Storage Failure", - "binary_sensor", - "problem", - None, - payload.Data.SimpleItem[0].Value == "true", - EntityCategory.DIAGNOSTIC, - ) - - -@PARSERS.register("tns1:Monitoring/ProcessorUsage") -async def async_parse_processor_usage(uid: str, msg) -> Event | None: - """Handle parsing event message. - - Topic: tns1:Monitoring/ProcessorUsage - """ - topic, payload = extract_message(msg) - usage = float(payload.Data.SimpleItem[0].Value) - if usage <= 1: - usage *= 100 - - return Event( - f"{uid}_{topic}", - "Processor Usage", - "sensor", - None, - "percent", - int(usage), - EntityCategory.DIAGNOSTIC, - ) - - -@PARSERS.register("tns1:Monitoring/OperatingTime/LastReboot") -async def async_parse_last_reboot(uid: str, msg) -> Event | None: - """Handle parsing event message. - - Topic: tns1:Monitoring/OperatingTime/LastReboot - """ - topic, payload = extract_message(msg) - date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) - return Event( - f"{uid}_{topic}", - "Last Reboot", - "sensor", - "timestamp", - None, - date_time, - EntityCategory.DIAGNOSTIC, - ) - - -@PARSERS.register("tns1:Monitoring/OperatingTime/LastReset") -async def async_parse_last_reset(uid: str, msg) -> Event | None: - """Handle parsing event message. - - Topic: tns1:Monitoring/OperatingTime/LastReset - """ - topic, payload = extract_message(msg) - date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) - return Event( - f"{uid}_{topic}", - "Last Reset", - "sensor", - "timestamp", - None, - date_time, - EntityCategory.DIAGNOSTIC, - entity_enabled=False, - ) - - -@PARSERS.register("tns1:Monitoring/Backup/Last") -async def async_parse_backup_last(uid: str, msg) -> Event | None: - """Handle parsing event message. - - Topic: tns1:Monitoring/Backup/Last - """ - topic, payload = extract_message(msg) - date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) - return Event( - f"{uid}_{topic}", - "Last Backup", - "sensor", - "timestamp", - None, - date_time, - EntityCategory.DIAGNOSTIC, - entity_enabled=False, - ) - - -@PARSERS.register("tns1:Monitoring/OperatingTime/LastClockSynchronization") -async def async_parse_last_clock_sync(uid: str, msg) -> Event | None: - """Handle parsing event message. - - Topic: tns1:Monitoring/OperatingTime/LastClockSynchronization - """ - topic, payload = extract_message(msg) - date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) - return Event( - f"{uid}_{topic}", - "Last Clock Synchronization", - "sensor", - "timestamp", - None, - date_time, - EntityCategory.DIAGNOSTIC, - entity_enabled=False, - ) - - -@PARSERS.register("tns1:RecordingConfig/JobState") -async def async_parse_jobstate(uid: str, msg) -> Event | None: - """Handle parsing event message. - - Topic: tns1:RecordingConfig/JobState - """ - - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Recording Job State", - "binary_sensor", - None, - None, - payload.Data.SimpleItem[0].Value == "Active", - EntityCategory.DIAGNOSTIC, - ) - - -@PARSERS.register("tns1:RuleEngine/LineDetector/Crossed") -async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None: - """Handle parsing event message. - - Topic: tns1:RuleEngine/LineDetector/Crossed - """ - video_source = "" - video_analytics = "" - rule = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "VideoSourceConfigurationToken": - video_source = source.Value - if source.Name == "VideoAnalyticsConfigurationToken": - video_analytics = source.Value - if source.Name == "Rule": - rule = source.Value - - return Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Line Detector Crossed", - "sensor", - None, - None, - payload.Data.SimpleItem[0].Value, - EntityCategory.DIAGNOSTIC, - ) - - -@PARSERS.register("tns1:RuleEngine/CountAggregation/Counter") -async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None: - """Handle parsing event message. - - Topic: tns1:RuleEngine/CountAggregation/Counter - """ - video_source = "" - video_analytics = "" - rule = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "VideoSourceConfigurationToken": - video_source = _normalize_video_source(source.Value) - if source.Name == "VideoAnalyticsConfigurationToken": - video_analytics = source.Value - if source.Name == "Rule": - rule = source.Value - - return Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Count Aggregation Counter", - "sensor", - None, - None, - payload.Data.SimpleItem[0].Value, - EntityCategory.DIAGNOSTIC, - ) - - -@PARSERS.register("tns1:UserAlarm/IVA/HumanShapeDetect") -async def async_parse_human_shape_detect(uid: str, msg) -> Event | None: - """Handle parsing event message. - - Topic: tns1:UserAlarm/IVA/HumanShapeDetect - """ - topic, payload = extract_message(msg) - video_source = "" - for source in payload.Source.SimpleItem: - if source.Name == "VideoSourceConfigurationToken": - video_source = _normalize_video_source(source.Value) - break - - return Event( - f"{uid}_{topic}_{video_source}", - "Human Shape Detect", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index fb13a38f824641..44fed05e1365d9 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -50,6 +50,7 @@ CONF_TOP_P, DEFAULT_AI_TASK_NAME, DEFAULT_NAME, + DEFAULT_STT_NAME, DEFAULT_TTS_NAME, DOMAIN, LOGGER, @@ -57,6 +58,7 @@ RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, RECOMMENDED_REASONING_EFFORT, + RECOMMENDED_STT_OPTIONS, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_P, RECOMMENDED_TTS_OPTIONS, @@ -66,7 +68,7 @@ SERVICE_GENERATE_IMAGE = "generate_image" SERVICE_GENERATE_CONTENT = "generate_content" -PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION, Platform.TTS) +PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION, Platform.STT, Platform.TTS) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) type OpenAIConfigEntry = ConfigEntry[openai.AsyncClient] @@ -480,6 +482,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> _add_tts_subentry(hass, entry) hass.config_entries.async_update_entry(entry, minor_version=5) + if entry.version == 2 and entry.minor_version == 5: + _add_stt_subentry(hass, entry) + hass.config_entries.async_update_entry(entry, minor_version=6) + LOGGER.debug( "Migration to version %s:%s successful", entry.version, entry.minor_version ) @@ -500,6 +506,19 @@ def _add_ai_task_subentry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> None ) +def _add_stt_subentry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> None: + """Add STT subentry to the config entry.""" + hass.config_entries.async_add_subentry( + entry, + ConfigSubentry( + data=MappingProxyType(RECOMMENDED_STT_OPTIONS), + subentry_type="stt", + title=DEFAULT_STT_NAME, + unique_id=None, + ), + ) + + def _add_tts_subentry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> None: """Add TTS subentry to the config entry.""" hass.config_entries.async_add_subentry( diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index 4cf05d77e177db..5843e2f36c8d45 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -54,6 +54,7 @@ CONF_REASONING_EFFORT, CONF_REASONING_SUMMARY, CONF_RECOMMENDED, + CONF_SERVICE_TIER, CONF_TEMPERATURE, CONF_TOP_P, CONF_TTS_SPEED, @@ -68,6 +69,8 @@ CONF_WEB_SEARCH_USER_LOCATION, DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, + DEFAULT_STT_NAME, + DEFAULT_STT_PROMPT, DEFAULT_TTS_NAME, DOMAIN, RECOMMENDED_AI_TASK_OPTIONS, @@ -78,6 +81,9 @@ RECOMMENDED_MAX_TOKENS, RECOMMENDED_REASONING_EFFORT, RECOMMENDED_REASONING_SUMMARY, + RECOMMENDED_SERVICE_TIER, + RECOMMENDED_STT_MODEL, + RECOMMENDED_STT_OPTIONS, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_P, RECOMMENDED_TTS_OPTIONS, @@ -88,8 +94,10 @@ RECOMMENDED_WEB_SEARCH_INLINE_CITATIONS, RECOMMENDED_WEB_SEARCH_USER_LOCATION, UNSUPPORTED_CODE_INTERPRETER_MODELS, + UNSUPPORTED_FLEX_SERVICE_TIERS_MODELS, UNSUPPORTED_IMAGE_MODELS, UNSUPPORTED_MODELS, + UNSUPPORTED_PRIORITY_SERVICE_TIERS_MODELS, UNSUPPORTED_WEB_SEARCH_MODELS, ) @@ -110,14 +118,14 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: client = openai.AsyncOpenAI( api_key=data[CONF_API_KEY], http_client=get_async_client(hass) ) - await hass.async_add_executor_job(client.with_options(timeout=10.0).models.list) + await client.models.list(timeout=10.0) class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for OpenAI Conversation.""" VERSION = 2 - MINOR_VERSION = 5 + MINOR_VERSION = 6 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -158,6 +166,12 @@ async def async_step_user( "title": DEFAULT_AI_TASK_NAME, "unique_id": None, }, + { + "subentry_type": "stt", + "data": RECOMMENDED_STT_OPTIONS, + "title": DEFAULT_STT_NAME, + "unique_id": None, + }, { "subentry_type": "tts", "data": RECOMMENDED_TTS_OPTIONS, @@ -204,6 +218,7 @@ def async_get_supported_subentry_types( return { "conversation": OpenAISubentryFlowHandler, "ai_task_data": OpenAISubentryFlowHandler, + "stt": OpenAISubentrySTTFlowHandler, "tts": OpenAISubentryTTSFlowHandler, } @@ -432,6 +447,25 @@ async def async_step_model( if not model.startswith("gpt-5"): options.pop(CONF_REASONING_SUMMARY) + service_tiers = self._get_service_tiers(model) + if "flex" in service_tiers or "priority" in service_tiers: + step_schema[ + vol.Optional( + CONF_SERVICE_TIER, + default=RECOMMENDED_SERVICE_TIER, + ) + ] = SelectSelector( + SelectSelectorConfig( + options=service_tiers, + translation_key=CONF_SERVICE_TIER, + mode=SelectSelectorMode.DROPDOWN, + ) + ) + else: + options.pop(CONF_SERVICE_TIER, None) + if options.get(CONF_SERVICE_TIER) not in service_tiers: + options.pop(CONF_SERVICE_TIER, None) + if self._subentry_type == "conversation" and not model.startswith( tuple(UNSUPPORTED_WEB_SEARCH_MODELS) ): @@ -501,6 +535,11 @@ async def async_step_model( options.pop(CONF_WEB_SEARCH_REGION, None) options.pop(CONF_WEB_SEARCH_COUNTRY, None) options.pop(CONF_WEB_SEARCH_TIMEZONE, None) + if ( + user_input.get(CONF_CODE_INTERPRETER) + and user_input.get(CONF_REASONING_EFFORT) == "minimal" + ): + errors[CONF_CODE_INTERPRETER] = "code_interpreter_minimal_reasoning" options.update(user_input) if not errors: @@ -528,19 +567,39 @@ def _get_reasoning_options(self, model: str) -> list[str]: if not model.startswith(("o", "gpt-5")) or model.startswith("gpt-5-pro"): return [] - MODELS_REASONING_MAP = { - "gpt-5.2-pro": ["medium", "high", "xhigh"], - "gpt-5.2": ["none", "low", "medium", "high", "xhigh"], + 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"): [ + "none", + "low", + "medium", + "high", + "xhigh", + ], "gpt-5.1": ["none", "low", "medium", "high"], "gpt-5": ["minimal", "low", "medium", "high"], "": ["low", "medium", "high"], # The default case } - for prefix, options in MODELS_REASONING_MAP.items(): + for prefix, options in models_reasoning_map.items(): if model.startswith(prefix): return options return [] # pragma: no cover + def _get_service_tiers(self, model: str) -> list[str]: + """Get service tier options based on model.""" + service_tiers = ["auto"] + + if not model.startswith(tuple(UNSUPPORTED_FLEX_SERVICE_TIERS_MODELS)): + service_tiers.append("flex") + + service_tiers.append("default") + + if not model.startswith(tuple(UNSUPPORTED_PRIORITY_SERVICE_TIERS_MODELS)): + service_tiers.append("priority") + + return service_tiers + async def _get_location_data(self) -> dict[str, str]: """Get approximate location data of the user.""" location_data: dict[str, str] = {} @@ -595,6 +654,95 @@ async def _get_location_data(self) -> dict[str, str]: return location_data +class OpenAISubentrySTTFlowHandler(ConfigSubentryFlow): + """Flow for managing OpenAI STT subentries.""" + + options: dict[str, Any] + + @property + def _is_new(self) -> bool: + """Return if this is a new subentry.""" + return self.source == "user" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Add a subentry.""" + self.options = RECOMMENDED_STT_OPTIONS.copy() + return await self.async_step_init() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle reconfiguration of a subentry.""" + self.options = self._get_reconfigure_subentry().data.copy() + return await self.async_step_init() + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Manage initial options.""" + # abort if entry is not loaded + if self._get_entry().state != ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + + options = self.options + errors: dict[str, str] = {} + + step_schema: VolDictType = {} + + if self._is_new: + step_schema[vol.Required(CONF_NAME, default=DEFAULT_STT_NAME)] = str + + step_schema.update( + { + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": options.get(CONF_PROMPT, DEFAULT_STT_PROMPT) + }, + ): TextSelector( + TextSelectorConfig(multiline=True, type=TextSelectorType.TEXT) + ), + vol.Optional( + CONF_CHAT_MODEL, default=RECOMMENDED_STT_MODEL + ): SelectSelector( + SelectSelectorConfig( + options=[ + "gpt-4o-transcribe", + "gpt-4o-mini-transcribe", + "whisper-1", + ], + mode=SelectSelectorMode.DROPDOWN, + custom_value=True, + ) + ), + } + ) + + if user_input is not None: + options.update(user_input) + if not errors: + if self._is_new: + return self.async_create_entry( + title=options.pop(CONF_NAME), + data=options, + ) + return self.async_update_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + data=options, + ) + + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + vol.Schema(step_schema), options + ), + errors=errors, + ) + + class OpenAISubentryTTSFlowHandler(ConfigSubentryFlow): """Flow for managing OpenAI TTS subentries.""" diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 50fe3d850734f2..2acf2aa9791593 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -1,6 +1,7 @@ """Constants for the OpenAI Conversation integration.""" import logging +from typing import Any from homeassistant.const import CONF_LLM_HASS_API from homeassistant.helpers import llm @@ -10,6 +11,7 @@ DEFAULT_CONVERSATION_NAME = "OpenAI Conversation" DEFAULT_AI_TASK_NAME = "OpenAI AI Task" +DEFAULT_STT_NAME = "OpenAI STT" DEFAULT_TTS_NAME = "OpenAI TTS" DEFAULT_NAME = "OpenAI Conversation" @@ -22,6 +24,7 @@ CONF_REASONING_EFFORT = "reasoning_effort" CONF_REASONING_SUMMARY = "reasoning_summary" CONF_RECOMMENDED = "recommended" +CONF_SERVICE_TIER = "service_tier" CONF_TEMPERATURE = "temperature" CONF_TOP_P = "top_p" CONF_TTS_SPEED = "tts_speed" @@ -40,6 +43,8 @@ RECOMMENDED_MAX_TOKENS = 3000 RECOMMENDED_REASONING_EFFORT = "low" RECOMMENDED_REASONING_SUMMARY = "auto" +RECOMMENDED_SERVICE_TIER = "auto" +RECOMMENDED_STT_MODEL = "gpt-4o-mini-transcribe" RECOMMENDED_TEMPERATURE = 1.0 RECOMMENDED_TOP_P = 1.0 RECOMMENDED_TTS_SPEED = 1.0 @@ -48,6 +53,9 @@ RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE = "medium" RECOMMENDED_WEB_SEARCH_USER_LOCATION = False RECOMMENDED_WEB_SEARCH_INLINE_CITATIONS = False +DEFAULT_STT_PROMPT = ( + "The following conversation is a smart home user talking to Home Assistant." +) UNSUPPORTED_MODELS: list[str] = [ "o1-mini", @@ -108,7 +116,43 @@ RECOMMENDED_AI_TASK_OPTIONS = { CONF_RECOMMENDED: True, } +RECOMMENDED_STT_OPTIONS: dict[str, Any] = {} RECOMMENDED_TTS_OPTIONS = { CONF_PROMPT: "", CONF_CHAT_MODEL: "gpt-4o-mini-tts", } + +UNSUPPORTED_FLEX_SERVICE_TIERS_MODELS: list[str] = [ + "gpt-5.3", + "gpt-5.2-chat", + "gpt-5.1-chat", + "gpt-5-chat", + "gpt-5.2-codex", + "gpt-5.1-codex", + "gpt-5-codex", + "gpt-5.2-pro", + "gpt-5-pro", + "gpt-4", + "o1", + "o3-pro", + "o3-deep-research", + "o4-mini-deep-research", + "o3-mini", + "codex-mini", +] +UNSUPPORTED_PRIORITY_SERVICE_TIERS_MODELS: list[str] = [ + "gpt-5-nano", + "gpt-5.3-chat", + "gpt-5.2-chat", + "gpt-5.1-chat", + "gpt-5.1-codex-mini", + "gpt-5-chat", + "gpt-5.2-pro", + "gpt-5-pro", + "o1", + "o3-pro", + "o3-deep-research", + "o4-mini-deep-research", + "o3-mini", + "codex-mini", +] diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 45352bdf3d584d..399da7ce4d85e8 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -74,6 +74,7 @@ CONF_MAX_TOKENS, CONF_REASONING_EFFORT, CONF_REASONING_SUMMARY, + CONF_SERVICE_TIER, CONF_TEMPERATURE, CONF_TOP_P, CONF_VERBOSITY, @@ -92,6 +93,8 @@ RECOMMENDED_MAX_TOKENS, RECOMMENDED_REASONING_EFFORT, RECOMMENDED_REASONING_SUMMARY, + RECOMMENDED_SERVICE_TIER, + RECOMMENDED_STT_MODEL, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_P, RECOMMENDED_VERBOSITY, @@ -471,7 +474,12 @@ def __init__(self, entry: OpenAIConfigEntry, subentry: ConfigSubentry) -> None: identifiers={(DOMAIN, subentry.subentry_id)}, name=subentry.title, manufacturer="OpenAI", - model=subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), + model=subentry.data.get( + CONF_CHAT_MODEL, + RECOMMENDED_CHAT_MODEL + if subentry.subentry_type != "stt" + else RECOMMENDED_STT_MODEL, + ), entry_type=dr.DeviceEntryType.SERVICE, ) @@ -493,6 +501,7 @@ async def _async_handle_chat_log( input=messages, 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, stream=True, ) @@ -649,6 +658,15 @@ async def _async_handle_chat_log( ) ) except openai.RateLimitError as err: + if ( + model_args["service_tier"] == "flex" + and "resource unavailable" in (err.message or "").lower() + ): + LOGGER.info( + "Flex tier is not available at the moment, continuing with default tier" + ) + model_args["service_tier"] = "default" + continue LOGGER.error("Rate limited by OpenAI: %s", err) raise HomeAssistantError("Rate limited or insufficient funds") from err except openai.OpenAIError as err: diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 12719678f2d7aa..178910ae0978f1 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -38,6 +38,7 @@ }, "entry_type": "AI task", "error": { + "code_interpreter_minimal_reasoning": "[%key:component::openai_conversation::config_subentries::conversation::error::code_interpreter_minimal_reasoning%]", "model_not_supported": "[%key:component::openai_conversation::config_subentries::conversation::error::model_not_supported%]", "web_search_minimal_reasoning": "[%key:component::openai_conversation::config_subentries::conversation::error::web_search_minimal_reasoning%]" }, @@ -69,6 +70,7 @@ "reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::reasoning_effort%]", "reasoning_summary": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::reasoning_summary%]", "search_context_size": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::search_context_size%]", + "service_tier": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::service_tier%]", "user_location": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::user_location%]", "web_search": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::web_search%]" }, @@ -79,6 +81,7 @@ "reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::reasoning_effort%]", "reasoning_summary": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::reasoning_summary%]", "search_context_size": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::search_context_size%]", + "service_tier": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::service_tier%]", "user_location": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::user_location%]", "web_search": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::web_search%]" }, @@ -93,6 +96,7 @@ }, "entry_type": "Conversation agent", "error": { + "code_interpreter_minimal_reasoning": "Code interpreter is not supported with minimal reasoning effort", "model_not_supported": "This model is not supported, please select a different model", "web_search_minimal_reasoning": "Web search is currently not supported with minimal reasoning effort" }, @@ -129,6 +133,7 @@ "reasoning_effort": "Reasoning effort", "reasoning_summary": "Reasoning summary", "search_context_size": "Search context size", + "service_tier": "Service tier", "user_location": "Include home location", "web_search": "Enable web search" }, @@ -139,6 +144,7 @@ "reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt", "reasoning_summary": "Controls the length and detail of reasoning summaries provided by the model", "search_context_size": "High level guidance for the amount of context window space to use for the search", + "service_tier": "Controls the cost and response time", "user_location": "Refine search results based on geography", "web_search": "Allow the model to search the web for the latest information before generating a response" }, @@ -146,6 +152,30 @@ } } }, + "stt": { + "abort": { + "entry_not_loaded": "[%key:component::openai_conversation::config_subentries::conversation::abort::entry_not_loaded%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "entry_type": "Speech-to-text", + "initiate_flow": { + "reconfigure": "Reconfigure speech-to-text service", + "user": "Add speech-to-text service" + }, + "step": { + "init": { + "data": { + "chat_model": "Model", + "name": "[%key:common::config_flow::data::name%]", + "prompt": "[%key:common::config_flow::data::prompt%]" + }, + "data_description": { + "chat_model": "The model to use to transcribe speech.", + "prompt": "Use this prompt to improve the quality of the transcripts. Translate to the pipeline language for best results. See the documentation for more details." + } + } + } + }, "tts": { "abort": { "entry_not_loaded": "[%key:component::openai_conversation::config_subentries::conversation::abort::entry_not_loaded%]", @@ -216,6 +246,14 @@ "medium": "[%key:common::state::medium%]" } }, + "service_tier": { + "options": { + "auto": "[%key:common::state::auto%]", + "default": "Standard", + "flex": "Flex", + "priority": "Priority" + } + }, "verbosity": { "options": { "high": "[%key:common::state::high%]", diff --git a/homeassistant/components/openai_conversation/stt.py b/homeassistant/components/openai_conversation/stt.py new file mode 100644 index 00000000000000..4542ead13ff191 --- /dev/null +++ b/homeassistant/components/openai_conversation/stt.py @@ -0,0 +1,196 @@ +"""Speech to text support for OpenAI.""" + +from __future__ import annotations + +from collections.abc import AsyncIterable +import io +import logging +from typing import TYPE_CHECKING +import wave + +from openai import OpenAIError + +from homeassistant.components import stt +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ( + CONF_CHAT_MODEL, + CONF_PROMPT, + DEFAULT_STT_PROMPT, + RECOMMENDED_STT_MODEL, +) +from .entity import OpenAIBaseLLMEntity + +if TYPE_CHECKING: + from . import OpenAIConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: OpenAIConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up STT entities.""" + for subentry in config_entry.subentries.values(): + if subentry.subentry_type != "stt": + continue + + async_add_entities( + [OpenAISTTEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) + + +class OpenAISTTEntity(stt.SpeechToTextEntity, OpenAIBaseLLMEntity): + """OpenAI Speech to text entity.""" + + @property + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + # https://developers.openai.com/api/docs/guides/speech-to-text#supported-languages + # The model may also transcribe the audio in other languages but with lower quality + return [ + "af-ZA", # Afrikaans + "ar-SA", # Arabic + "hy-AM", # Armenian + "az-AZ", # Azerbaijani + "be-BY", # Belarusian + "bs-BA", # Bosnian + "bg-BG", # Bulgarian + "ca-ES", # Catalan + "zh-CN", # Chinese (Mandarin) + "hr-HR", # Croatian + "cs-CZ", # Czech + "da-DK", # Danish + "nl-NL", # Dutch + "en-US", # English + "et-EE", # Estonian + "fi-FI", # Finnish + "fr-FR", # French + "gl-ES", # Galician + "de-DE", # German + "el-GR", # Greek + "he-IL", # Hebrew + "hi-IN", # Hindi + "hu-HU", # Hungarian + "is-IS", # Icelandic + "id-ID", # Indonesian + "it-IT", # Italian + "ja-JP", # Japanese + "kn-IN", # Kannada + "kk-KZ", # Kazakh + "ko-KR", # Korean + "lv-LV", # Latvian + "lt-LT", # Lithuanian + "mk-MK", # Macedonian + "ms-MY", # Malay + "mr-IN", # Marathi + "mi-NZ", # Maori + "ne-NP", # Nepali + "no-NO", # Norwegian + "fa-IR", # Persian + "pl-PL", # Polish + "pt-PT", # Portuguese + "ro-RO", # Romanian + "ru-RU", # Russian + "sr-RS", # Serbian + "sk-SK", # Slovak + "sl-SI", # Slovenian + "es-ES", # Spanish + "sw-KE", # Swahili + "sv-SE", # Swedish + "fil-PH", # Tagalog (Filipino) + "ta-IN", # Tamil + "th-TH", # Thai + "tr-TR", # Turkish + "uk-UA", # Ukrainian + "ur-PK", # Urdu + "vi-VN", # Vietnamese + "cy-GB", # Welsh + ] + + @property + def supported_formats(self) -> list[stt.AudioFormats]: + """Return a list of supported formats.""" + # https://developers.openai.com/api/docs/guides/speech-to-text#transcriptions + return [stt.AudioFormats.WAV, stt.AudioFormats.OGG] + + @property + def supported_codecs(self) -> list[stt.AudioCodecs]: + """Return a list of supported codecs.""" + return [stt.AudioCodecs.PCM, stt.AudioCodecs.OPUS] + + @property + def supported_bit_rates(self) -> list[stt.AudioBitRates]: + """Return a list of supported bit rates.""" + return [ + stt.AudioBitRates.BITRATE_8, + stt.AudioBitRates.BITRATE_16, + stt.AudioBitRates.BITRATE_24, + stt.AudioBitRates.BITRATE_32, + ] + + @property + def supported_sample_rates(self) -> list[stt.AudioSampleRates]: + """Return a list of supported sample rates.""" + return [ + stt.AudioSampleRates.SAMPLERATE_8000, + stt.AudioSampleRates.SAMPLERATE_11000, + stt.AudioSampleRates.SAMPLERATE_16000, + stt.AudioSampleRates.SAMPLERATE_18900, + stt.AudioSampleRates.SAMPLERATE_22000, + stt.AudioSampleRates.SAMPLERATE_32000, + stt.AudioSampleRates.SAMPLERATE_37800, + stt.AudioSampleRates.SAMPLERATE_44100, + stt.AudioSampleRates.SAMPLERATE_48000, + ] + + @property + def supported_channels(self) -> list[stt.AudioChannels]: + """Return a list of supported channels.""" + return [stt.AudioChannels.CHANNEL_MONO, stt.AudioChannels.CHANNEL_STEREO] + + async def async_process_audio_stream( + self, metadata: stt.SpeechMetadata, stream: AsyncIterable[bytes] + ) -> stt.SpeechResult: + """Process an audio stream to STT service.""" + audio_bytes = bytearray() + async for chunk in stream: + audio_bytes.extend(chunk) + audio_data = bytes(audio_bytes) + if metadata.format == stt.AudioFormats.WAV: + # Add missing wav header + wav_buffer = io.BytesIO() + + with wave.open(wav_buffer, "wb") as wf: + wf.setnchannels(metadata.channel.value) + wf.setsampwidth(metadata.bit_rate.value // 8) + wf.setframerate(metadata.sample_rate.value) + wf.writeframes(audio_data) + + audio_data = wav_buffer.getvalue() + + options = self.subentry.data + client = self.entry.runtime_data + + try: + response = await client.audio.transcriptions.create( + model=options.get(CONF_CHAT_MODEL, RECOMMENDED_STT_MODEL), + file=(f"a.{metadata.format.value}", audio_data), + response_format="json", + language=metadata.language.split("-")[0], + prompt=options.get(CONF_PROMPT, DEFAULT_STT_PROMPT), + ) + except OpenAIError: + _LOGGER.exception("Error during STT") + else: + if response.text: + return stt.SpeechResult( + response.text, + stt.SpeechResultState.SUCCESS, + ) + + return stt.SpeechResult(None, stt.SpeechResultState.ERROR) diff --git a/homeassistant/components/opendisplay/__init__.py b/homeassistant/components/opendisplay/__init__.py new file mode 100644 index 00000000000000..53f161a6c70b4e --- /dev/null +++ b/homeassistant/components/opendisplay/__init__.py @@ -0,0 +1,127 @@ +"""Integration for OpenDisplay BLE e-paper displays.""" + +from __future__ import annotations + +import asyncio +import contextlib +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from opendisplay import ( + BLEConnectionError, + BLETimeoutError, + GlobalConfig, + OpenDisplayDevice, + OpenDisplayError, +) + +from homeassistant.components.bluetooth import async_ble_device_from_address +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import 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 + +if TYPE_CHECKING: + from opendisplay.models import FirmwareVersion + +from .const import DOMAIN +from .services import async_setup_services + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +@dataclass +class OpenDisplayRuntimeData: + """Runtime data for an OpenDisplay config entry.""" + + firmware: FirmwareVersion + device_config: GlobalConfig + is_flex: bool + upload_task: asyncio.Task | None = None + + +type OpenDisplayConfigEntry = ConfigEntry[OpenDisplayRuntimeData] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the OpenDisplay integration.""" + async_setup_services(hass) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry) -> bool: + """Set up OpenDisplay from a config entry.""" + address = entry.unique_id + if TYPE_CHECKING: + assert address is not None + + ble_device = async_ble_device_from_address(hass, address, connectable=True) + if ble_device is None: + raise ConfigEntryNotReady( + f"Could not find OpenDisplay device with address {address}" + ) + + try: + async with OpenDisplayDevice( + mac_address=address, ble_device=ble_device + ) as device: + fw = await device.read_firmware_version() + is_flex = device.is_flex + except (BLEConnectionError, BLETimeoutError, OpenDisplayError) as err: + raise ConfigEntryNotReady( + f"Failed to connect to OpenDisplay device: {err}" + ) from err + device_config = device.config + if TYPE_CHECKING: + assert device_config is not None + + entry.runtime_data = OpenDisplayRuntimeData( + firmware=fw, + device_config=device_config, + is_flex=is_flex, + ) + + # 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 + color_scheme = ( + str(color_scheme_enum) + if isinstance(color_scheme_enum, int) + else color_scheme_enum.name + ) + size = ( + f'{display.screen_diagonal_inches:.1f}"' + 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}" + if is_flex + else None, + configuration_url="https://opendisplay.org/firmware/config/" + if is_flex + else None, + ) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: OpenDisplayConfigEntry +) -> bool: + """Unload a config entry.""" + if (task := entry.runtime_data.upload_task) and not task.done(): + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + return True diff --git a/homeassistant/components/opendisplay/config_flow.py b/homeassistant/components/opendisplay/config_flow.py new file mode 100644 index 00000000000000..9dc37489eb8809 --- /dev/null +++ b/homeassistant/components/opendisplay/config_flow.py @@ -0,0 +1,130 @@ +"""Config flow for OpenDisplay integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from opendisplay import ( + MANUFACTURER_ID, + BLEConnectionError, + OpenDisplayDevice, + OpenDisplayError, +) +import voluptuous as vol + +from homeassistant.components.bluetooth import ( + BluetoothServiceInfoBleak, + async_ble_device_from_address, + async_discovered_service_info, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ADDRESS + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class OpenDisplayConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for OpenDisplay.""" + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovery_info: BluetoothServiceInfoBleak | None = None + self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {} + + async def _async_test_connection(self, address: str) -> 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 + ) as device: + await device.read_firmware_version() + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfoBleak + ) -> ConfigFlowResult: + """Handle the Bluetooth discovery step.""" + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured() + self._discovery_info = discovery_info + self.context["title_placeholders"] = {"name": discovery_info.name} + + try: + await self._async_test_connection(discovery_info.address) + except OpenDisplayError: + return self.async_abort(reason="cannot_connect") + except Exception: + _LOGGER.exception("Unexpected error") + return self.async_abort(reason="unknown") + + return await self.async_step_bluetooth_confirm() + + async def async_step_bluetooth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + assert self._discovery_info is not None + + if user_input is None: + self._set_confirm_only() + return self.async_show_form( + step_id="bluetooth_confirm", + description_placeholders=self.context["title_placeholders"], + ) + + return self.async_create_entry(title=self._discovery_info.name, data={}) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step to pick discovered device.""" + errors: dict[str, str] = {} + + if user_input is not None: + address = user_input[CONF_ADDRESS] + await self.async_set_unique_id(address, raise_on_progress=False) + self._abort_if_unique_id_configured() + + try: + await self._async_test_connection(address) + except OpenDisplayError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=self._discovered_devices[address].name, + data={}, + ) + else: + current_addresses = self._async_current_ids(include_ignore=False) + for discovery_info in async_discovered_service_info(self.hass): + address = discovery_info.address + if address in current_addresses or address in self._discovered_devices: + continue + if MANUFACTURER_ID in discovery_info.manufacturer_data: + self._discovered_devices[address] = discovery_info + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_ADDRESS): vol.In( + { + addr: f"{info.name} ({addr})" + for addr, info in self._discovered_devices.items() + } + ) + } + ), + errors=errors, + ) diff --git a/homeassistant/components/opendisplay/const.py b/homeassistant/components/opendisplay/const.py new file mode 100644 index 00000000000000..0db0b2f08fde49 --- /dev/null +++ b/homeassistant/components/opendisplay/const.py @@ -0,0 +1,3 @@ +"""Constants for the OpenDisplay integration.""" + +DOMAIN = "opendisplay" diff --git a/homeassistant/components/opendisplay/diagnostics.py b/homeassistant/components/opendisplay/diagnostics.py new file mode 100644 index 00000000000000..f4d5375b5c888c --- /dev/null +++ b/homeassistant/components/opendisplay/diagnostics.py @@ -0,0 +1,42 @@ +"""Diagnostics support for OpenDisplay.""" + +from __future__ import annotations + +import dataclasses +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from . import OpenDisplayConfigEntry + +TO_REDACT = {"ssid", "password", "server_url"} + + +def _asdict(obj: Any) -> Any: + """Recursively convert a dataclass to a dict, encoding bytes as hex strings.""" + if dataclasses.is_dataclass(obj) and not isinstance(obj, type): + return {f.name: _asdict(getattr(obj, f.name)) for f in dataclasses.fields(obj)} + if isinstance(obj, bytes): + return obj.hex() + if isinstance(obj, list): + return [_asdict(item) for item in obj] + return obj + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: OpenDisplayConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + runtime = entry.runtime_data + fw = runtime.firmware + + return { + "firmware": { + "major": fw["major"], + "minor": fw["minor"], + "sha": fw["sha"], + }, + "is_flex": runtime.is_flex, + "device_config": async_redact_data(_asdict(runtime.device_config), TO_REDACT), + } diff --git a/homeassistant/components/opendisplay/icons.json b/homeassistant/components/opendisplay/icons.json new file mode 100644 index 00000000000000..e3e394c341a385 --- /dev/null +++ b/homeassistant/components/opendisplay/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "upload_image": { + "service": "mdi:image-move" + } + } +} diff --git a/homeassistant/components/opendisplay/manifest.json b/homeassistant/components/opendisplay/manifest.json new file mode 100644 index 00000000000000..a18e17a01efcca --- /dev/null +++ b/homeassistant/components/opendisplay/manifest.json @@ -0,0 +1,18 @@ +{ + "domain": "opendisplay", + "name": "OpenDisplay", + "bluetooth": [ + { + "connectable": true, + "manufacturer_id": 9286 + } + ], + "codeowners": ["@g4bri3lDev"], + "config_flow": true, + "dependencies": ["bluetooth_adapters", "http"], + "documentation": "https://www.home-assistant.io/integrations/opendisplay", + "integration_type": "device", + "iot_class": "local_push", + "quality_scale": "silver", + "requirements": ["py-opendisplay==5.5.0"] +} diff --git a/homeassistant/components/opendisplay/quality_scale.yaml b/homeassistant/components/opendisplay/quality_scale.yaml new file mode 100644 index 00000000000000..720ec101aac442 --- /dev/null +++ b/homeassistant/components/opendisplay/quality_scale.yaml @@ -0,0 +1,103 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: + status: exempt + 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. + 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: + 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. + 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 has no options flow. + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: Integration does not currently provide any entities. + 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. + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + 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-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: 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. + exception-translations: done + icon-translations: done + reconfiguration-flow: + status: exempt + comment: Reconfiguration would require selecting a new device, which is a new config entry. + repair-issues: + status: exempt + comment: Integration does not use repair issues. + stale-devices: + status: exempt + comment: Stale devices are removed with the config entry as there is only one device per entry. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: The opendisplay library communicates over BLE and does not use HTTP. + strict-typing: todo diff --git a/homeassistant/components/opendisplay/services.py b/homeassistant/components/opendisplay/services.py new file mode 100644 index 00000000000000..98de6f677f9c34 --- /dev/null +++ b/homeassistant/components/opendisplay/services.py @@ -0,0 +1,228 @@ +"""Service registration for the OpenDisplay integration.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +import contextlib +from datetime import timedelta +from enum import IntEnum +import io +from typing import TYPE_CHECKING, Any + +import aiohttp +from opendisplay import ( + DitherMode, + FitMode, + OpenDisplayDevice, + OpenDisplayError, + RefreshMode, + Rotation, +) +from PIL import Image as PILImage, ImageOps +import voluptuous as vol + +from homeassistant.components.bluetooth import async_ble_device_from_address +from homeassistant.components.http.auth import async_sign_path +from homeassistant.components.media_source import async_resolve_media +from homeassistant.config_entries import ConfigEntryState +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 +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH +from homeassistant.helpers.network import get_url +from homeassistant.helpers.selector import MediaSelector, MediaSelectorConfig + +if TYPE_CHECKING: + from . import OpenDisplayConfigEntry + +from .const import DOMAIN + +ATTR_IMAGE = "image" +ATTR_ROTATION = "rotation" +ATTR_DITHER_MODE = "dither_mode" +ATTR_REFRESH_MODE = "refresh_mode" +ATTR_FIT_MODE = "fit_mode" +ATTR_TONE_COMPRESSION = "tone_compression" + + +def _str_to_int_enum(enum_class: type[IntEnum]) -> Callable[[str], Any]: + """Return a validator that converts a lowercase enum name string to an enum member.""" + members = {m.name.lower(): m for m in enum_class} + + def validate(value: str) -> IntEnum: + if (result := members.get(value)) is None: + raise vol.Invalid(f"Invalid value: {value}") + return result + + return validate + + +SCHEMA_UPLOAD_IMAGE = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): cv.string, + vol.Required(ATTR_IMAGE): MediaSelector( + MediaSelectorConfig(accept=["image/*"]) + ), + vol.Optional(ATTR_ROTATION, default=Rotation.ROTATE_0): vol.All( + vol.Coerce(int), vol.Coerce(Rotation) + ), + vol.Optional(ATTR_DITHER_MODE, default="burkes"): _str_to_int_enum(DitherMode), + vol.Optional(ATTR_REFRESH_MODE, default="full"): _str_to_int_enum(RefreshMode), + vol.Optional(ATTR_FIT_MODE, default="contain"): _str_to_int_enum(FitMode), + vol.Optional(ATTR_TONE_COMPRESSION): vol.All( + vol.Coerce(float), vol.Range(min=0.0, max=100.0) + ), + } +) + + +def _get_entry_for_device(call: ServiceCall) -> OpenDisplayConfigEntry: + """Return the config entry for the device targeted by a service call.""" + device_id: str = call.data[ATTR_DEVICE_ID] + device_registry = dr.async_get(call.hass) + + if (device := device_registry.async_get(device_id)) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_device_id", + translation_placeholders={"device_id": device_id}, + ) + + mac_address = next( + (conn[1] for conn in device.connections if conn[0] == CONNECTION_BLUETOOTH), + None, + ) + if mac_address is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_device_id", + translation_placeholders={"device_id": device_id}, + ) + + entry = call.hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, mac_address + ) + if entry is None or entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={"address": mac_address}, + ) + + return entry + + +def _load_image(path: str) -> PILImage.Image: + """Load an image from disk and apply EXIF orientation.""" + image = PILImage.open(path) + image.load() + return ImageOps.exif_transpose(image) + + +def _load_image_from_bytes(data: bytes) -> PILImage.Image: + """Load an image from bytes and apply EXIF orientation.""" + image = PILImage.open(io.BytesIO(data)) + image.load() + return ImageOps.exif_transpose(image) + + +async def _async_download_image(hass: HomeAssistant, url: str) -> PILImage.Image: + """Download an image from a URL and return a PIL Image.""" + if not url.startswith(("http://", "https://")): + url = get_url(hass) + async_sign_path( + hass, url, timedelta(minutes=5), use_content_user=True + ) + session = async_get_clientsession(hass) + try: + async with session.get(url) as resp: + resp.raise_for_status() + data = await resp.read() + except aiohttp.ClientError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="media_download_error", + translation_placeholders={"error": str(err)}, + ) from err + + return await hass.async_add_executor_job(_load_image_from_bytes, data) + + +async def _async_upload_image(call: ServiceCall) -> None: + """Handle the upload_image service call.""" + entry = _get_entry_for_device(call) + address = entry.unique_id + assert address is not None + + image_data: dict[str, Any] = call.data[ATTR_IMAGE] + rotation: Rotation = call.data[ATTR_ROTATION] + dither_mode: DitherMode = call.data[ATTR_DITHER_MODE] + refresh_mode: RefreshMode = call.data[ATTR_REFRESH_MODE] + fit_mode: FitMode = call.data[ATTR_FIT_MODE] + tone_compression_pct: float | None = call.data.get(ATTR_TONE_COMPRESSION) + tone_compression: float | str = ( + tone_compression_pct / 100.0 if tone_compression_pct is not None else "auto" + ) + + ble_device = async_ble_device_from_address(call.hass, address, connectable=True) + if ble_device is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={"address": address}, + ) + + current = asyncio.current_task() + if (prev := entry.runtime_data.upload_task) is not None and not prev.done(): + prev.cancel() + with contextlib.suppress(asyncio.CancelledError): + await prev + entry.runtime_data.upload_task = current + + try: + media = await async_resolve_media( + call.hass, image_data["media_content_id"], None + ) + + if media.path is not None: + pil_image = await call.hass.async_add_executor_job( + _load_image, str(media.path) + ) + else: + pil_image = await _async_download_image(call.hass, media.url) + + async with OpenDisplayDevice( + mac_address=address, + ble_device=ble_device, + config=entry.runtime_data.device_config, + ) as device: + await device.upload_image( + pil_image, + refresh_mode=refresh_mode, + dither_mode=dither_mode, + tone_compression=tone_compression, + fit=fit_mode, + rotate=rotation, + ) + except asyncio.CancelledError: + return + except OpenDisplayError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="upload_error" + ) from err + finally: + if entry.runtime_data.upload_task is current: + entry.runtime_data.upload_task = None + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register OpenDisplay services.""" + hass.services.async_register( + DOMAIN, + "upload_image", + _async_upload_image, + schema=SCHEMA_UPLOAD_IMAGE, + ) diff --git a/homeassistant/components/opendisplay/services.yaml b/homeassistant/components/opendisplay/services.yaml new file mode 100644 index 00000000000000..880da3711cbd65 --- /dev/null +++ b/homeassistant/components/opendisplay/services.yaml @@ -0,0 +1,70 @@ +upload_image: + fields: + device_id: + required: true + selector: + device: + integration: opendisplay + image: + required: true + selector: + media: + accept: + - image/* + advanced_options: + collapsed: true + fields: + rotation: + required: false + default: 0 + selector: + number: + min: 0 + max: 270 + step: 90 + mode: slider + dither_mode: + required: false + default: "burkes" + selector: + select: + translation_key: dither_mode + options: + - "none" + - "burkes" + - "ordered" + - "floyd_steinberg" + - "atkinson" + - "stucki" + - "sierra" + - "sierra_lite" + - "jarvis_judice_ninke" + refresh_mode: + required: false + default: "full" + selector: + select: + translation_key: refresh_mode + options: + - "full" + - "fast" + fit_mode: + required: false + default: "contain" + selector: + select: + translation_key: fit_mode + options: + - "stretch" + - "contain" + - "cover" + - "crop" + tone_compression: + required: false + selector: + number: + min: 0 + max: 100 + step: 1 + mode: slider + unit_of_measurement: "%" diff --git a/homeassistant/components/opendisplay/strings.json b/homeassistant/components/opendisplay/strings.json new file mode 100644 index 00000000000000..85f1236a60f2bd --- /dev/null +++ b/homeassistant/components/opendisplay/strings.json @@ -0,0 +1,114 @@ +{ + "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%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + }, + "user": { + "data": { + "address": "[%key:common::config_flow::data::device%]" + }, + "data_description": { + "address": "Select the Bluetooth device to set up." + }, + "description": "[%key:component::bluetooth::config::step::user::description%]" + } + } + }, + "exceptions": { + "device_not_found": { + "message": "Could not find Bluetooth device with address `{address}`." + }, + "invalid_device_id": { + "message": "Device `{device_id}` is not a valid OpenDisplay device." + }, + "media_download_error": { + "message": "Failed to download media: {error}" + }, + "upload_error": { + "message": "Failed to upload image to the display." + } + }, + "selector": { + "dither_mode": { + "options": { + "atkinson": "Atkinson", + "burkes": "Burkes", + "floyd_steinberg": "Floyd-Steinberg", + "jarvis_judice_ninke": "Jarvis, Judice & Ninke", + "none": "None", + "ordered": "Ordered", + "sierra": "Sierra", + "sierra_lite": "Sierra Lite", + "stucki": "Stucki" + } + }, + "fit_mode": { + "options": { + "contain": "Contain", + "cover": "Cover", + "crop": "Crop", + "stretch": "Stretch" + } + }, + "refresh_mode": { + "options": { + "fast": "Fast", + "full": "Full" + } + } + }, + "services": { + "upload_image": { + "description": "Uploads an image to an OpenDisplay device.", + "fields": { + "device_id": { + "description": "The OpenDisplay device to upload the image to.", + "name": "Device" + }, + "dither_mode": { + "description": "The dithering algorithm to use for converting the image to the display's color palette.", + "name": "Dither mode" + }, + "fit_mode": { + "description": "How the image is fitted to the display dimensions.", + "name": "Fit mode" + }, + "image": { + "description": "The image to upload to the display.", + "name": "Image" + }, + "refresh_mode": { + "description": "The display refresh mode. Full refresh clears ghosting but is slower. Fast refresh is not supported on all displays.", + "name": "Refresh mode" + }, + "rotation": { + "description": "The rotation angle in degrees, applied clockwise.", + "name": "Rotation" + }, + "tone_compression": { + "description": "Dynamic range compression strength. Leave empty for automatic.", + "name": "Tone compression" + } + }, + "name": "Upload image", + "sections": { + "advanced_options": { + "name": "Advanced options" + } + } + } + } +} diff --git a/homeassistant/components/openevse/__init__.py b/homeassistant/components/openevse/__init__.py index c1c786f0b77342..1e792d19ba63d6 100644 --- a/homeassistant/components/openevse/__init__.py +++ b/homeassistant/components/openevse/__init__.py @@ -7,6 +7,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator @@ -19,6 +20,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenEVSEConfigEntry) -> entry.data[CONF_HOST], entry.data.get(CONF_USERNAME), entry.data.get(CONF_PASSWORD), + session=async_get_clientsession(hass), ) try: diff --git a/homeassistant/components/openevse/config_flow.py b/homeassistant/components/openevse/config_flow.py index 129de7635fcf9a..264b306654c714 100644 --- a/homeassistant/components/openevse/config_flow.py +++ b/homeassistant/components/openevse/config_flow.py @@ -9,6 +9,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.service_info import zeroconf from .const import CONF_ID, CONF_SERIAL, DOMAIN @@ -36,7 +37,9 @@ async def check_status( ) -> tuple[dict[str, str], str | None]: """Check if we can connect to the OpenEVSE charger.""" - charger = OpenEVSE(host, user, password) + charger = OpenEVSE( + host, user, password, session=async_get_clientsession(self.hass) + ) try: result = await charger.test_and_get() except TimeoutError: diff --git a/homeassistant/components/openevse/manifest.json b/homeassistant/components/openevse/manifest.json index 1809f307bee2fd..3902ac70ca444f 100644 --- a/homeassistant/components/openevse/manifest.json +++ b/homeassistant/components/openevse/manifest.json @@ -9,6 +9,6 @@ "iot_class": "local_push", "loggers": ["openevsehttp"], "quality_scale": "bronze", - "requirements": ["python-openevse-http==0.2.1"], + "requirements": ["python-openevse-http==0.2.5"], "zeroconf": ["_openevse._tcp.local."] } diff --git a/homeassistant/components/openevse/quality_scale.yaml b/homeassistant/components/openevse/quality_scale.yaml index 0f010474272fff..da2bf2cf8d3ca5 100644 --- a/homeassistant/components/openevse/quality_scale.yaml +++ b/homeassistant/components/openevse/quality_scale.yaml @@ -70,5 +70,5 @@ rules: # Platinum async-dependency: done - inject-websession: todo + inject-websession: done strict-typing: todo diff --git a/homeassistant/components/openhardwaremonitor/sensor.py b/homeassistant/components/openhardwaremonitor/sensor.py index 4aa334da3a7cb5..fe8511b4416ff8 100644 --- a/homeassistant/components/openhardwaremonitor/sensor.py +++ b/homeassistant/components/openhardwaremonitor/sensor.py @@ -65,38 +65,13 @@ class OpenHardwareMonitorDevice(SensorEntity): def __init__(self, data, name, path, unit_of_measurement): """Initialize an OpenHardwareMonitor sensor.""" - self._name = name + self._attr_name = name self._data = data self.path = path - self.attributes = {} - self._unit_of_measurement = unit_of_measurement - - self.value = None - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit_of_measurement - - @property - def native_value(self): - """Return the state of the device.""" - if self.value == "-": - return None - return self.value - - @property - def extra_state_attributes(self): - """Return the state attributes of the entity.""" - return self.attributes + self._attr_native_unit_of_measurement = unit_of_measurement @classmethod - def parse_number(cls, string): + def parse_number(cls, string: str) -> str: """In some locales a decimal numbers uses ',' instead of '.'.""" return string.replace(",", ".") @@ -111,7 +86,8 @@ def update(self) -> None: values = array[path_number] if path_index == len(self.path) - 1: - self.value = self.parse_number(values[OHM_VALUE].split(" ")[0]) + value = self.parse_number(values[OHM_VALUE].split(" ")[0]) + self._attr_native_value = None if value == "-" else value _attributes.update( { "name": values[OHM_NAME], @@ -124,7 +100,7 @@ def update(self) -> None: } ) - self.attributes = _attributes + self._attr_extra_state_attributes = _attributes return array = array[path_number][OHM_CHILDREN] _attributes.update({f"level_{path_index}": values[OHM_NAME]}) diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index ed6376b14fa9d6..d53706b315c302 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -431,6 +431,7 @@ async def _async_maybe_migrate_statistics( for source_id, source_stats in existing_stats.items(): _LOGGER.debug("Found %d statistics for %s", len(source_stats), source_id) if not source_stats: + need_migration_source_ids.remove(source_id) continue target_id = migration_map[source_id] diff --git a/homeassistant/components/opower/diagnostics.py b/homeassistant/components/opower/diagnostics.py new file mode 100644 index 00000000000000..23f695cbfda87f --- /dev/null +++ b/homeassistant/components/opower/diagnostics.py @@ -0,0 +1,73 @@ +"""Diagnostics support for Opower.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .const import CONF_LOGIN_DATA, CONF_TOTP_SECRET +from .coordinator import OpowerConfigEntry + +TO_REDACT = { + CONF_PASSWORD, + CONF_USERNAME, + CONF_LOGIN_DATA, + CONF_TOTP_SECRET, + # Title contains the username/email + "title", + "utility_account_id", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: OpowerConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + + return async_redact_data( + { + "entry": entry.as_dict(), + "data": [ + { + "account": { + "utility_account_id": account.utility_account_id, + "meter_type": account.meter_type.name, + "read_resolution": ( + account.read_resolution.name + if account.read_resolution + else None + ), + }, + "forecast": ( + { + "usage_to_date": forecast.usage_to_date, + "cost_to_date": forecast.cost_to_date, + "forecasted_usage": forecast.forecasted_usage, + "forecasted_cost": forecast.forecasted_cost, + "typical_usage": forecast.typical_usage, + "typical_cost": forecast.typical_cost, + "unit_of_measure": forecast.unit_of_measure.name, + "start_date": forecast.start_date.isoformat(), + "end_date": forecast.end_date.isoformat(), + "current_date": forecast.current_date.isoformat(), + } + if (forecast := data.forecast) + else None + ), + "last_changed": ( + data.last_changed.isoformat() if data.last_changed else None + ), + "last_updated": ( + data.last_updated.isoformat() if data.last_updated else None + ), + } + for data in coordinator.data.values() + for account in (data.account,) + ], + }, + TO_REDACT, + ) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index e1a1a65082d59e..3938071abd2b26 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -8,6 +8,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["opower"], - "quality_scale": "bronze", - "requirements": ["opower==0.17.0"] + "quality_scale": "platinum", + "requirements": ["opower==0.17.1"] } diff --git a/homeassistant/components/opower/quality_scale.yaml b/homeassistant/components/opower/quality_scale.yaml index 77b97763db514d..c51fa99c8fff10 100644 --- a/homeassistant/components/opower/quality_scale.yaml +++ b/homeassistant/components/opower/quality_scale.yaml @@ -39,12 +39,12 @@ rules: log-when-unavailable: done parallel-updates: done reauthentication-flow: done - test-coverage: todo + test-coverage: done # Gold devices: status: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: The integration does not support discovery. @@ -58,7 +58,7 @@ 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 @@ -71,7 +71,7 @@ rules: status: exempt comment: The integration has no user-configurable options that are not authentication-related. repair-issues: done - stale-devices: todo + stale-devices: done # Platinum async-dependency: done diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 72dccb1eebf51a..08341146a484ff 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -15,7 +15,8 @@ SensorStateClass, ) from homeassistant.const import EntityCategory, UnitOfEnergy, UnitOfVolume -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType @@ -207,48 +208,102 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Opower sensor.""" - coordinator = entry.runtime_data - entities: list[OpowerSensor] = [] - opower_data_list = coordinator.data.values() - for opower_data in opower_data_list: - account = opower_data.account - forecast = opower_data.forecast - device_id = ( - f"{coordinator.api.utility.subdomain()}_{account.utility_account_id}" - ) - device = DeviceInfo( - identifiers={(DOMAIN, device_id)}, - name=f"{account.meter_type.name} account {account.utility_account_id}", - manufacturer="Opower", - model=coordinator.api.utility.name(), - entry_type=DeviceEntryType.SERVICE, - ) - sensors: tuple[OpowerEntityDescription, ...] = COMMON_SENSORS - if ( - account.meter_type == MeterType.ELEC - and forecast is not None - and forecast.unit_of_measure == UnitOfMeasure.KWH - ): - sensors += ELEC_SENSORS - elif ( - account.meter_type == MeterType.GAS - and forecast is not None - and forecast.unit_of_measure in [UnitOfMeasure.THERM, UnitOfMeasure.CCF] + created_sensors: set[tuple[str, str]] = set() + + @callback + def _update_entities() -> None: + """Update entities.""" + new_entities: list[OpowerSensor] = [] + current_account_device_ids: set[str] = set() + current_account_ids: set[str] = set() + + for opower_data in coordinator.data.values(): + account = opower_data.account + forecast = opower_data.forecast + device_id = ( + f"{coordinator.api.utility.subdomain()}_{account.utility_account_id}" + ) + current_account_device_ids.add(device_id) + current_account_ids.add(account.utility_account_id) + device = DeviceInfo( + identifiers={(DOMAIN, device_id)}, + name=f"{account.meter_type.name} account {account.utility_account_id}", + manufacturer="Opower", + model=coordinator.api.utility.name(), + entry_type=DeviceEntryType.SERVICE, + ) + sensors: tuple[OpowerEntityDescription, ...] = COMMON_SENSORS + if ( + account.meter_type == MeterType.ELEC + and forecast is not None + and forecast.unit_of_measure == UnitOfMeasure.KWH + ): + sensors += ELEC_SENSORS + elif ( + account.meter_type == MeterType.GAS + and forecast is not None + and forecast.unit_of_measure in [UnitOfMeasure.THERM, UnitOfMeasure.CCF] + ): + sensors += GAS_SENSORS + for sensor in sensors: + sensor_key = (account.utility_account_id, sensor.key) + if sensor_key in created_sensors: + continue + created_sensors.add(sensor_key) + new_entities.append( + OpowerSensor( + coordinator, + sensor, + account.utility_account_id, + device, + device_id, + ) + ) + + if new_entities: + async_add_entities(new_entities) + + # Remove any registered devices not in the current coordinator data + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + for device_entry in dr.async_entries_for_config_entry( + device_registry, entry.entry_id ): - sensors += GAS_SENSORS - entities.extend( - OpowerSensor( - coordinator, - sensor, - account.utility_account_id, - device, - device_id, + device_domain_ids = { + identifier[1] + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + } + if not device_domain_ids: + # This device has no Opower identifiers; it may be a merged/shared + # device owned by another integration. Do not alter it here. + continue + if not device_domain_ids.isdisjoint(current_account_device_ids): + continue # device is still active + # Device is stale — remove its entities then detach it + for entity_entry in er.async_entries_for_device( + entity_registry, device_entry.id, include_disabled_entities=True + ): + if entity_entry.config_entry_id != entry.entry_id: + continue + entity_registry.async_remove(entity_entry.entity_id) + device_registry.async_update_device( + device_entry.id, remove_config_entry_id=entry.entry_id ) - for sensor in sensors - ) - async_add_entities(entities) + # Prune sensor tracking for accounts that are no longer present + if created_sensors: + stale_sensor_keys = { + sensor_key + for sensor_key in created_sensors + if sensor_key[0] not in current_account_ids + } + if stale_sensor_keys: + created_sensors.difference_update(stale_sensor_keys) + + _update_entities() + entry.async_on_unload(coordinator.async_add_listener(_update_entities)) class OpowerSensor(CoordinatorEntity[OpowerCoordinator], SensorEntity): @@ -272,6 +327,11 @@ def __init__( self._attr_device_info = device self.utility_account_id = utility_account_id + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.utility_account_id in self.coordinator.data + @property def native_value(self) -> StateType | date | datetime: """Return the state.""" diff --git a/homeassistant/components/opple/light.py b/homeassistant/components/opple/light.py index e804f06faa31db..2dba3b130f2895 100644 --- a/homeassistant/components/opple/light.py +++ b/homeassistant/components/opple/light.py @@ -62,9 +62,7 @@ def __init__(self, name, host): self._device = OppleLightDevice(host) - self._name = name - self._is_on = None - self._brightness = None + self._attr_name = name @property def available(self) -> bool: @@ -76,21 +74,6 @@ def unique_id(self): """Return unique ID for light.""" return self._device.mac - @property - def name(self): - """Return the display name of this light.""" - return self._name - - @property - def is_on(self): - """Return true if light is on.""" - return self._is_on - - @property - def brightness(self): - """Return the brightness of the light.""" - return self._brightness - def turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" _LOGGER.debug("Turn on light %s %s", self._device.ip, kwargs) @@ -118,8 +101,8 @@ def update(self) -> None: if ( prev_available == self.available - and self._is_on == self._device.power_on - and self._brightness == self._device.brightness + and self._attr_is_on == self._device.power_on + and self._attr_brightness == self._device.brightness and self._attr_color_temp_kelvin == self._device.color_temperature ): return @@ -128,8 +111,8 @@ def update(self) -> None: _LOGGER.debug("Light %s is offline", self._device.ip) return - self._is_on = self._device.power_on - self._brightness = self._device.brightness + self._attr_is_on = self._device.power_on + self._attr_brightness = self._device.brightness self._attr_color_temp_kelvin = self._device.color_temperature if not self.is_on: @@ -138,6 +121,6 @@ def update(self) -> None: _LOGGER.debug( "Update light %s success: power on brightness %s color temperature %s", self._device.ip, - self._brightness, + self._attr_brightness, self._attr_color_temp_kelvin, ) diff --git a/homeassistant/components/orvibo/__init__.py b/homeassistant/components/orvibo/__init__.py index 81cddecb672192..71c6e0609c5944 100644 --- a/homeassistant/components/orvibo/__init__.py +++ b/homeassistant/components/orvibo/__init__.py @@ -1 +1,51 @@ -"""The orvibo component.""" +"""The orvibo integration.""" + +import logging + +from orvibo.s20 import S20, S20Exception + +from homeassistant import core +from homeassistant.const import CONF_HOST, CONF_MAC, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN +from .models import S20ConfigEntry + +PLATFORMS = [Platform.SWITCH] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: core.HomeAssistant, entry: S20ConfigEntry) -> bool: + """Set up platform from a ConfigEntry.""" + + try: + s20 = await hass.async_add_executor_job( + S20, + entry.data[CONF_HOST], + entry.data[CONF_MAC], + ) + _LOGGER.debug("Initialized S20 at %s", entry.data[CONF_HOST]) + except S20Exception as err: + _LOGGER.debug("S20 at %s couldn't be initialized", entry.data[CONF_HOST]) + + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="init_error", + translation_placeholders={ + "host": entry.data[CONF_HOST], + }, + ) from err + + entry.runtime_data = s20 + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: S20ConfigEntry) -> bool: + """Unload a config entry.""" + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/orvibo/config_flow.py b/homeassistant/components/orvibo/config_flow.py new file mode 100644 index 00000000000000..13f914e094ec7d --- /dev/null +++ b/homeassistant/components/orvibo/config_flow.py @@ -0,0 +1,205 @@ +"""Config flow for the orvibo integration.""" + +import asyncio +import logging +from typing import Any + +from orvibo.s20 import S20, S20Exception, discover +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.device_registry import format_mac + +from .const import CONF_SWITCH_LIST, DEFAULT_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +FULL_EDIT_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_MAC): cv.string, + } +) + + +class S20ConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle the config flow for Orvibo S20 switches.""" + + VERSION = 1 + MINOR_VERSION = 1 + + def __init__(self) -> None: + """Initialize an instance of the S20 config flow.""" + self.discovery_task: asyncio.Task | None = None + self._discovered_switches: dict[str, dict[str, Any]] = {} + self.chosen_switch: dict[str, Any] = {} + + async def _async_discover(self) -> None: + def _filter_discovered_switches( + switches: dict[str, dict[str, Any]], + ) -> dict[str, dict[str, Any]]: + # Get existing unique_ids from config entries + existing_ids = {entry.unique_id for entry in self._async_current_entries()} + _LOGGER.debug("Existing unique IDs: %s", existing_ids) + # Build a new filtered dict + filtered = {} + for ip, info in switches.items(): + mac_bytes = info.get("mac") + if not mac_bytes: + continue # skip if no MAC + + unique_id = format_mac(mac_bytes.hex()).lower() + if unique_id not in existing_ids: + filtered[ip] = info + _LOGGER.debug("New switches: %s", filtered) + return filtered + + # Discover S20 devices. + _LOGGER.debug("Discovering S20 switches") + + _unfiltered_switches = await self.hass.async_add_executor_job(discover) + _LOGGER.debug("All discovered switches: %s", _unfiltered_switches) + + self._discovered_switches = _filter_discovered_switches(_unfiltered_switches) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + + return self.async_show_menu( + step_id="user", menu_options=["start_discovery", "edit"] + ) + + async def _validate_input(self, user_input: dict[str, Any]) -> str | None: + """Validate user input and discover MAC if missing.""" + + if user_input.get(CONF_MAC): + user_input[CONF_MAC] = format_mac(user_input[CONF_MAC]).lower() + if len(user_input[CONF_MAC]) != 17 or user_input[CONF_MAC].count(":") != 5: + return "invalid_mac" + + try: + device = await self.hass.async_add_executor_job( + S20, + user_input[CONF_HOST], + user_input.get(CONF_MAC), + ) + + if not user_input.get(CONF_MAC): + # Using private attribute access here since S20 class doesn't have a public method to get the MAC without repeating discovery + if not device._mac: # noqa: SLF001 + return "cannot_discover" + user_input[CONF_MAC] = format_mac(device._mac.hex()).lower() # noqa: SLF001 + + except S20Exception: + return "cannot_connect" + + return None + + async def async_step_edit( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Edit a discovered or manually configured server.""" + + errors = {} + if user_input: + error = await self._validate_input(user_input) + if not error: + await self.async_set_unique_id(user_input[CONF_MAC]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"{DEFAULT_NAME} ({user_input[CONF_HOST]})", data=user_input + ) + errors["base"] = error + + return self.async_show_form( + step_id="edit", + data_schema=FULL_EDIT_SCHEMA, + errors=errors, + ) + + async def async_step_start_discovery( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + if not self.discovery_task: + self.discovery_task = self.hass.async_create_task(self._async_discover()) + return self.async_show_progress( + step_id="start_discovery", + progress_action="start_discovery", + progress_task=self.discovery_task, + ) + if self.discovery_task.done(): + try: + self.discovery_task.result() + except (S20Exception, OSError) as err: + _LOGGER.debug("Discovery task failed: %s", err) + self.discovery_task = None + return self.async_show_progress_done( + next_step_id=( + "choose_switch" if self._discovered_switches else "discovery_failed" + ) + ) + return self.async_show_progress( + step_id="start_discovery", + progress_action="start_discovery", + progress_task=self.discovery_task, + ) + + async def async_step_choose_switch( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Choose manual or discover flow.""" + _chosen_host: str + + if user_input: + _chosen_host = user_input[CONF_SWITCH_LIST] + for host, data in self._discovered_switches.items(): + if _chosen_host == host: + self.chosen_switch[CONF_HOST] = host + self.chosen_switch[CONF_MAC] = format_mac( + data[CONF_MAC].hex() + ).lower() + await self.async_set_unique_id(self.chosen_switch[CONF_MAC]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"{DEFAULT_NAME} ({host})", data=self.chosen_switch + ) + _LOGGER.debug("discovered switches: %s", self._discovered_switches) + + _options = { + host: f"{host} ({format_mac(data[CONF_MAC].hex()).lower()})" + for host, data in self._discovered_switches.items() + } + return self.async_show_form( + step_id="choose_switch", + data_schema=vol.Schema({vol.Required(CONF_SWITCH_LIST): vol.In(_options)}), + ) + + async def async_step_discovery_failed( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a failed discovery.""" + + return self.async_show_menu( + step_id="discovery_failed", menu_options=["start_discovery", "edit"] + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult: + """Handle import from configuration.yaml.""" + _LOGGER.debug("Importing config: %s", user_input) + + error = await self._validate_input(user_input) + if error: + return self.async_abort(reason=error) + + await self.async_set_unique_id(user_input[CONF_MAC]) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=user_input.get(CONF_NAME, user_input[CONF_HOST]), data=user_input + ) diff --git a/homeassistant/components/orvibo/const.py b/homeassistant/components/orvibo/const.py new file mode 100644 index 00000000000000..0286588ddbe218 --- /dev/null +++ b/homeassistant/components/orvibo/const.py @@ -0,0 +1,5 @@ +"""Constants for the orvibo integration.""" + +DOMAIN = "orvibo" +DEFAULT_NAME = "S20" +CONF_SWITCH_LIST = "switches" diff --git a/homeassistant/components/orvibo/manifest.json b/homeassistant/components/orvibo/manifest.json index e3a6676b2f2f8d..10559c4dadcc62 100644 --- a/homeassistant/components/orvibo/manifest.json +++ b/homeassistant/components/orvibo/manifest.json @@ -2,7 +2,9 @@ "domain": "orvibo", "name": "Orvibo", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/orvibo", + "integration_type": "device", "iot_class": "local_push", "loggers": ["orvibo"], "quality_scale": "legacy", diff --git a/homeassistant/components/orvibo/models.py b/homeassistant/components/orvibo/models.py new file mode 100644 index 00000000000000..d702ecef61ac07 --- /dev/null +++ b/homeassistant/components/orvibo/models.py @@ -0,0 +1,7 @@ +"""Data models for the Orvibo integration.""" + +from orvibo.s20 import S20 + +from homeassistant.config_entries import ConfigEntry + +type S20ConfigEntry = ConfigEntry[S20] diff --git a/homeassistant/components/orvibo/strings.json b/homeassistant/components/orvibo/strings.json new file mode 100644 index 00000000000000..93ab02b755e0b2 --- /dev/null +++ b/homeassistant/components/orvibo/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%]", + "cannot_connect": "Unable to connect to the S20 switch", + "cannot_discover": "Unable to discover MAC address of S20 switch. Please enter the MAC address.", + "invalid_mac": "Invalid MAC address format" + }, + "error": { + "cannot_connect": "[%key:component::orvibo::config::abort::cannot_connect%]", + "cannot_discover": "[%key:component::orvibo::config::abort::cannot_discover%]", + "invalid_mac": "Invalid MAC address format" + }, + "progress": { + "start_discovery": "Attempting to discover new S20 switches\n\nThis will take about 3 seconds\n\nDiscovery may fail if the switch is asleep. If your switch does not appear, please power toggle your switch before re-running discovery.", + "title": "Orvibo S20" + }, + "step": { + "choose_switch": { + "data": { + "switches": "Choose discovered switch to configure" + }, + "title": "Discovered switches" + }, + "discovery_failed": { + "description": "No S20 switches were discovered on the network. Discovery may have failed if the switch is asleep. Please power toggle your switch before re-running discovery.", + "menu_options": { + "edit": "Enter configuration manually", + "start_discovery": "Try discovering again" + }, + "title": "Discovery failed" + }, + "edit": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "mac": "MAC address" + }, + "title": "Configure Orvibo S20 switch" + }, + "user": { + "menu_options": { + "edit": "Enter configuration manually", + "start_discovery": "Discover new S20 switches" + }, + "title": "Orvibo S20 Configuration" + } + } + }, + "exceptions": { + "init_error": { + "message": "Error while initializing S20 {host}." + }, + "turn_off_error": { + "message": "Error while turning off S20 {name}." + }, + "turn_on_error": { + "message": "Error while turning on S20 {name}." + } + }, + "issues": { + "yaml_deprecation": { + "description": "The device (MAC: {mac}, Host: {host}) is configured in `configuration.yaml`. The Orvibo integration now supports UI-based configuration and this device has been migrated to the new UI. Please remove the YAML block from `configuration.yaml` to avoid future issues.", + "title": "Legacy YAML configuration detected {host}" + }, + "yaml_deprecation_import_issue": { + "description": "Attempting to import this device (MAC: {mac}, Host: {host}) from YAML has failed for reason {reason}. 1) Remove the YAML block from `configuration.yaml`, 2) Restart Home Assistant, 3) Add the device using the UI configuration flow.", + "title": "Legacy YAML configuration import issue for {host}" + } + } +} diff --git a/homeassistant/components/orvibo/switch.py b/homeassistant/components/orvibo/switch.py index 211abc838e7df0..a7a829d7b66b7a 100644 --- a/homeassistant/components/orvibo/switch.py +++ b/homeassistant/components/orvibo/switch.py @@ -1,13 +1,14 @@ -"""Support for Orvibo S20 Wifi Smart Switches.""" +"""Switch platform for the Orvibo integration.""" from __future__ import annotations import logging from typing import Any -from orvibo.s20 import S20, S20Exception, discover +from orvibo.s20 import S20, S20Exception import voluptuous as vol +from homeassistant import config_entries from homeassistant.components.switch import ( PLATFORM_SCHEMA as SWITCH_PLATFORM_SCHEMA, SwitchEntity, @@ -20,14 +21,25 @@ CONF_SWITCHES, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import DEFAULT_NAME, DOMAIN +from .models import S20ConfigEntry + _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = "Orvibo S20 Switch" -DEFAULT_DISCOVERY = True +DEFAULT_DISCOVERY = False + +# Library is not thread safe and uses global variables, so we limit to 1 update at a time +PARALLEL_UPDATES = 1 PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend( { @@ -46,75 +58,138 @@ ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, add_entities_callback: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up S20 switches.""" - - switch_data = {} - switches = [] - switch_conf = config.get(CONF_SWITCHES, [config]) - - if config.get(CONF_DISCOVERY): - _LOGGER.debug("Discovering S20 switches") - switch_data.update(discover()) - - for switch in switch_conf: - switch_data[switch.get(CONF_HOST)] = switch - - for host, data in switch_data.items(): - try: - switches.append( - S20Switch(data.get(CONF_NAME), S20(host, mac=data.get(CONF_MAC))) + """Set up the integration from configuration.yaml.""" + for switch in config.get(CONF_SWITCHES, []): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=switch, + ) + + if ( + result.get("type") is FlowResultType.ABORT + and result.get("reason") != "already_configured" + ): + ir.async_create_issue( + hass, + DOMAIN, + f"yaml_deprecation_import_issue_{switch.get('host')}_{(switch.get('mac') or 'unknown_mac').replace(':', '').lower()}", + breaks_in_ha_version="2026.9.0", + is_fixable=False, + is_persistent=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="yaml_deprecation_import_issue", + translation_placeholders={ + "reason": str(result.get("reason")), + "host": switch.get("host"), + "mac": switch.get("mac", ""), + }, ) - _LOGGER.debug("Initialized S20 at %s", host) - except S20Exception: - _LOGGER.error("S20 at %s couldn't be initialized", host) - - add_entities_callback(switches) + continue + + ir.async_create_issue( + hass, + DOMAIN, + f"yaml_deprecation_{switch.get('host')}_{(switch.get('mac') or 'unknown_mac').replace(':', '').lower()}", + breaks_in_ha_version="2026.9.0", + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key="yaml_deprecation", + translation_placeholders={ + "host": switch.get("host"), + "mac": switch.get("mac") or "Unknown MAC", + }, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: S20ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up orvibo from a config entry.""" + async_add_entities( + [ + S20Switch( + entry.title, + entry.data[CONF_HOST], + entry.data[CONF_MAC], + entry.runtime_data, + ) + ] + ) class S20Switch(SwitchEntity): """Representation of an S20 switch.""" - def __init__(self, name, s20): + _attr_has_entity_name = True + + def __init__(self, name: str, host: str, mac: str, s20: S20) -> None: """Initialize the S20 device.""" - self._name = name + self._attr_is_on = False + self._host = host + self._mac = mac self._s20 = s20 - self._state = False - self._exc = S20Exception - - @property - def name(self): - """Return the name of the switch.""" - return self._name - - @property - def is_on(self): - """Return true if device is on.""" - return self._state - - def update(self) -> None: - """Update device state.""" - try: - self._state = self._s20.on - except self._exc: - _LOGGER.exception("Error while fetching S20 state") + self._attr_unique_id = self._mac + self._name = name + self._attr_name = None + self._attr_device_info = DeviceInfo( + identifiers={ + # MAC addresses are used as unique identifiers within this domain + (DOMAIN, self._attr_unique_id) + }, + name=name, + manufacturer="Orvibo", + model="S20", + connections={(CONNECTION_NETWORK_MAC, self._mac)}, + ) def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" try: self._s20.on = True - except self._exc: - _LOGGER.exception("Error while turning on S20") + except S20Exception as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="turn_on_error", + translation_placeholders={"name": self._name}, + ) from err def turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" try: self._s20.on = False - except self._exc: - _LOGGER.exception("Error while turning off S20") + except S20Exception as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="turn_off_error", + translation_placeholders={"name": self._name}, + ) from err + + def update(self) -> None: + """Update device state.""" + try: + self._attr_is_on = self._s20.on + + # If the device was previously offline, let the user know it's back! + if not self._attr_available: + _LOGGER.info("Orvibo switch %s reconnected", self._name) + self._attr_available = True + + except S20Exception as err: + # Only log the error if this is the FIRST time it failed + if self._attr_available: + _LOGGER.info( + "Error communicating with Orvibo switch %s: %s", self._name, err + ) + self._attr_available = False diff --git a/homeassistant/components/osramlightify/light.py b/homeassistant/components/osramlightify/light.py index 42af6c74e45125..8dad03d4bba22d 100644 --- a/homeassistant/components/osramlightify/light.py +++ b/homeassistant/components/osramlightify/light.py @@ -187,13 +187,8 @@ def __init__(self, luminary, update_func, changed): self._luminary = luminary self._changed = changed - self._unique_id = None - self._effect_list = [] - self._is_on = False - self._available = True - self._brightness = None + self._attr_is_on = False self._rgb_color = None - self._device_attributes = None self.update_static_attributes() self.update_dynamic_attributes() @@ -249,40 +244,10 @@ def name(self): return self._luminary.name() @property - def hs_color(self): + def hs_color(self) -> tuple[float, float]: """Return last hs color value set.""" return color_util.color_RGB_to_hs(*self._rgb_color) - @property - def brightness(self): - """Return brightness of the luminary (0..255).""" - return self._brightness - - @property - def is_on(self): - """Return True if the device is on.""" - return self._is_on - - @property - def effect_list(self): - """List of supported effects.""" - return self._effect_list - - @property - def unique_id(self): - """Return a unique ID.""" - return self._unique_id - - @property - def extra_state_attributes(self): - """Return device specific state attributes.""" - return self._device_attributes - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - def play_effect(self, effect, transition): """Play selected effect.""" if effect == EFFECT_RANDOM: @@ -313,19 +278,19 @@ def turn_on(self, **kwargs: Any) -> None: self._attr_color_temp_kelvin = color_temp_kelvin self._luminary.set_temperature(color_temp_kelvin, transition) - self._is_on = True + self._attr_is_on = True if ATTR_BRIGHTNESS in kwargs: - self._brightness = kwargs[ATTR_BRIGHTNESS] - self._luminary.set_luminance(int(self._brightness / 2.55), transition) + self._attr_brightness = kwargs[ATTR_BRIGHTNESS] + self._luminary.set_luminance(int(self._attr_brightness / 2.55), transition) else: self._luminary.set_onoff(True) def turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - self._is_on = False + self._attr_is_on = False if ATTR_TRANSITION in kwargs: transition = int(kwargs[ATTR_TRANSITION] * 10) - self._brightness = DEFAULT_BRIGHTNESS + self._attr_brightness = DEFAULT_BRIGHTNESS self._luminary.set_luminance(0, transition) else: self._luminary.set_onoff(False) @@ -337,10 +302,10 @@ def update_luminary(self, luminary): def update_static_attributes(self) -> None: """Update static attributes of the luminary.""" - self._unique_id = self._get_unique_id() + self._attr_unique_id = self._get_unique_id() self._attr_supported_color_modes = self._get_supported_color_modes() self._attr_supported_features = self._get_supported_features() - self._effect_list = self._get_effect_list() + self._attr_effect_list = self._get_effect_list() if ColorMode.COLOR_TEMP in self._attr_supported_color_modes: self._attr_max_color_temp_kelvin = ( self._luminary.max_temp() or DEFAULT_KELVIN @@ -354,10 +319,12 @@ def update_static_attributes(self) -> None: def update_dynamic_attributes(self): """Update dynamic attributes of the luminary.""" - self._is_on = self._luminary.on() - self._available = self._luminary.reachable() and not self._luminary.deleted() + self._attr_is_on = self._luminary.on() + self._attr_available = ( + self._luminary.reachable() and not self._luminary.deleted() + ) if brightness_supported(self._attr_supported_color_modes): - self._brightness = int(self._luminary.lum() * 2.55) + self._attr_brightness = int(self._luminary.lum() * 2.55) if ColorMode.COLOR_TEMP in self._attr_supported_color_modes: self._attr_color_temp_kelvin = self._luminary.temp() or DEFAULT_KELVIN @@ -399,7 +366,7 @@ def update_static_attributes(self): if self._luminary.devicetype().name == "SENSOR": attrs["sensor_values"] = self._luminary.raw_values() - self._device_attributes = attrs + self._attr_extra_state_attributes = attrs class OsramLightifyGroup(Luminary): @@ -444,4 +411,4 @@ def play_effect(self, effect, transition): def update_static_attributes(self): """Update static attributes of the luminary.""" super().update_static_attributes() - self._device_attributes = {"lights": self._luminary.light_names()} + self._attr_extra_state_attributes = {"lights": self._luminary.light_names()} diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index b4651898ecac94..0a33ca835e4e7e 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.8.0"] + "requirements": ["python-otbr-api==2.9.0"] } diff --git a/homeassistant/components/overseerr/config_flow.py b/homeassistant/components/overseerr/config_flow.py index 9a8bdd1676fbb2..e095f03354411d 100644 --- a/homeassistant/components/overseerr/config_flow.py +++ b/homeassistant/components/overseerr/config_flow.py @@ -69,7 +69,7 @@ async def async_step_user( else: if self.source == SOURCE_USER: return self.async_create_entry( - title="Overseerr", + title="Seerr", data={ CONF_HOST: host, CONF_PORT: port, diff --git a/homeassistant/components/overseerr/manifest.json b/homeassistant/components/overseerr/manifest.json index 031c13122c9535..f6097427eec5e4 100644 --- a/homeassistant/components/overseerr/manifest.json +++ b/homeassistant/components/overseerr/manifest.json @@ -1,6 +1,6 @@ { "domain": "overseerr", - "name": "Overseerr", + "name": "Seerr", "after_dependencies": ["cloud"], "codeowners": ["@joostlek", "@AmGarera"], "config_flow": true, @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "local_push", "quality_scale": "platinum", - "requirements": ["python-overseerr==0.8.0"] + "requirements": ["python-overseerr==0.9.0"] } diff --git a/homeassistant/components/overseerr/services.py b/homeassistant/components/overseerr/services.py index 7ccb5f882ac89a..5354102472cafa 100644 --- a/homeassistant/components/overseerr/services.py +++ b/homeassistant/components/overseerr/services.py @@ -79,6 +79,14 @@ async def _async_get_requests(call: ServiceCall) -> ServiceResponse: req["media"] = await _get_media( client, request.media.media_type, request.media.tmdb_id ) + for user in (req["modified_by"], req["requested_by"]): + del user["avatar_e_tag"] + del user["avatar_version"] + del user["permissions"] + del user["recovery_link_expiration_date"] + del user["settings"] + del user["user_type"] + del user["warnings"] result.append(req) return {"requests": cast(list[JsonValueType], result)} diff --git a/homeassistant/components/overseerr/strings.json b/homeassistant/components/overseerr/strings.json index 39ef4f7481c279..9ddfc6929f6d47 100644 --- a/homeassistant/components/overseerr/strings.json +++ b/homeassistant/components/overseerr/strings.json @@ -25,8 +25,8 @@ "url": "[%key:common::config_flow::data::url%]" }, "data_description": { - "api_key": "The API key of the Overseerr instance.", - "url": "The URL of the Overseerr instance." + "api_key": "The API key of the Seerr instance.", + "url": "The URL of the Seerr instance." } } } @@ -114,7 +114,7 @@ "message": "[%key:common::config_flow::error::invalid_api_key%]" }, "connection_error": { - "message": "Error connecting to the Overseerr instance: {error}" + "message": "Error connecting to the Seerr instance: {error}" } }, "selector": { @@ -137,11 +137,11 @@ }, "services": { "get_requests": { - "description": "Retrieves a list of media requests from Overseerr.", + "description": "Retrieves a list of media requests from Seerr.", "fields": { "config_entry_id": { - "description": "The Overseerr instance to get requests from.", - "name": "Overseerr instance" + "description": "The Seerr instance to get requests from.", + "name": "Seerr instance" }, "requested_by": { "description": "Filter the requests by the user ID that requested them.", diff --git a/homeassistant/components/peco/__init__.py b/homeassistant/components/peco/__init__.py index 1de5d4bb6a20e5..9dd32ecf14c093 100644 --- a/homeassistant/components/peco/__init__.py +++ b/homeassistant/components/peco/__init__.py @@ -2,78 +2,21 @@ from __future__ import annotations -from dataclasses import dataclass -from datetime import timedelta from typing import Final -from peco import ( - AlertResults, - BadJSONError, - HttpError, - OutageResults, - PecoOutageApi, - UnresponsiveMeterError, -) - from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ( - CONF_COUNTY, - CONF_PHONE_NUMBER, - DOMAIN, - LOGGER, - OUTAGE_SCAN_INTERVAL, - SMART_METER_SCAN_INTERVAL, -) +from .const import CONF_PHONE_NUMBER, DOMAIN +from .coordinator import PecoOutageCoordinator, PecoSmartMeterCoordinator PLATFORMS: Final = [Platform.BINARY_SENSOR, Platform.SENSOR] -@dataclass -class PECOCoordinatorData: - """Something to hold the data for PECO.""" - - outages: OutageResults - alerts: AlertResults - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up PECO Outage Counter from a config entry.""" - - websession = async_get_clientsession(hass) - api = PecoOutageApi() - # Outage Counter Setup - county: str = entry.data[CONF_COUNTY] - - async def async_update_outage_data() -> PECOCoordinatorData: - """Fetch data from API.""" - try: - outages: OutageResults = ( - await api.get_outage_totals(websession) - if county == "TOTAL" - else await api.get_outage_count(county, websession) - ) - alerts: AlertResults = await api.get_map_alerts(websession) - data = PECOCoordinatorData(outages, alerts) - except HttpError as err: - raise UpdateFailed(f"Error fetching data: {err}") from err - except BadJSONError as err: - raise UpdateFailed(f"Error parsing data: {err}") from err - return data - - outage_coordinator = DataUpdateCoordinator( - hass, - LOGGER, - config_entry=entry, - name="PECO Outage Count", - update_method=async_update_outage_data, - update_interval=timedelta(minutes=OUTAGE_SCAN_INTERVAL), - ) - + outage_coordinator = PecoOutageCoordinator(hass, entry) await outage_coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { @@ -81,31 +24,8 @@ async def async_update_outage_data() -> PECOCoordinatorData: } if phone_number := entry.data.get(CONF_PHONE_NUMBER): - # Smart Meter Setup] - - async def async_update_meter_data() -> bool: - """Fetch data from API.""" - try: - data: bool = await api.meter_check(phone_number, websession) - except UnresponsiveMeterError as err: - raise UpdateFailed("Unresponsive meter") from err - except HttpError as err: - raise UpdateFailed(f"Error fetching data: {err}") from err - except BadJSONError as err: - raise UpdateFailed(f"Error parsing data: {err}") from err - return data - - meter_coordinator = DataUpdateCoordinator( - hass, - LOGGER, - config_entry=entry, - name="PECO Smart Meter", - update_method=async_update_meter_data, - update_interval=timedelta(minutes=SMART_METER_SCAN_INTERVAL), - ) - + 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 await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/peco/binary_sensor.py b/homeassistant/components/peco/binary_sensor.py index a4d59a8c9a22df..86ec12a399987e 100644 --- a/homeassistant/components/peco/binary_sensor.py +++ b/homeassistant/components/peco/binary_sensor.py @@ -11,12 +11,10 @@ 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, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import PecoSmartMeterCoordinator PARALLEL_UPDATES: Final = 0 @@ -29,7 +27,7 @@ async def async_setup_entry( """Set up binary sensor for PECO.""" if "smart_meter" not in hass.data[DOMAIN][config_entry.entry_id]: return - coordinator: DataUpdateCoordinator[bool] = hass.data[DOMAIN][config_entry.entry_id][ + coordinator: PecoSmartMeterCoordinator = hass.data[DOMAIN][config_entry.entry_id][ "smart_meter" ] @@ -39,7 +37,7 @@ async def async_setup_entry( class PecoBinarySensor( - CoordinatorEntity[DataUpdateCoordinator[bool]], BinarySensorEntity + CoordinatorEntity[PecoSmartMeterCoordinator], BinarySensorEntity ): """Binary sensor for PECO outage counter.""" @@ -48,7 +46,7 @@ class PecoBinarySensor( _attr_name = "Meter Status" def __init__( - self, coordinator: DataUpdateCoordinator[bool], phone_number: str + self, coordinator: PecoSmartMeterCoordinator, phone_number: str ) -> None: """Initialize binary sensor for PECO.""" super().__init__(coordinator) diff --git a/homeassistant/components/peco/coordinator.py b/homeassistant/components/peco/coordinator.py new file mode 100644 index 00000000000000..0ecc6d23ef22b6 --- /dev/null +++ b/homeassistant/components/peco/coordinator.py @@ -0,0 +1,95 @@ +"""DataUpdateCoordinator for the PECO Outage Counter integration.""" + +from dataclasses import dataclass +from datetime import timedelta + +from peco import ( + AlertResults, + BadJSONError, + HttpError, + OutageResults, + PecoOutageApi, + UnresponsiveMeterError, +) + +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, UpdateFailed + +from .const import CONF_COUNTY, LOGGER, OUTAGE_SCAN_INTERVAL, SMART_METER_SCAN_INTERVAL + + +@dataclass +class PECOCoordinatorData: + """Data class to hold PECO outage and alert results.""" + + outages: OutageResults + alerts: AlertResults + + +class PecoOutageCoordinator(DataUpdateCoordinator[PECOCoordinatorData]): + """Coordinator for PECO outage data.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the outage coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=entry, + name="PECO Outage Count", + update_interval=timedelta(minutes=OUTAGE_SCAN_INTERVAL), + ) + self._api = PecoOutageApi() + self._websession = async_get_clientsession(hass) + self._county: str = entry.data[CONF_COUNTY] + + async def _async_update_data(self) -> PECOCoordinatorData: + """Fetch data from API.""" + try: + outages = ( + await self._api.get_outage_totals(self._websession) + if self._county == "TOTAL" + else await self._api.get_outage_count(self._county, self._websession) + ) + alerts = await self._api.get_map_alerts(self._websession) + except HttpError as err: + raise UpdateFailed(f"Error fetching data: {err}") from err + except BadJSONError as err: + raise UpdateFailed(f"Error parsing data: {err}") from err + return PECOCoordinatorData(outages, alerts) + + +class PecoSmartMeterCoordinator(DataUpdateCoordinator[bool]): + """Coordinator for PECO smart meter data.""" + + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, phone_number: str + ) -> None: + """Initialize the smart meter coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=entry, + name="PECO Smart Meter", + update_interval=timedelta(minutes=SMART_METER_SCAN_INTERVAL), + ) + self._api = PecoOutageApi() + self._websession = async_get_clientsession(hass) + self._phone_number = phone_number + + async def _async_update_data(self) -> bool: + """Fetch data from API.""" + try: + data = await self._api.meter_check(self._phone_number, self._websession) + except UnresponsiveMeterError as err: + raise UpdateFailed("Unresponsive meter") from err + except HttpError as err: + raise UpdateFailed(f"Error fetching data: {err}") from err + except BadJSONError as err: + raise UpdateFailed(f"Error parsing data: {err}") from err + return data diff --git a/homeassistant/components/peco/sensor.py b/homeassistant/components/peco/sensor.py index eafa36c98e9aa0..a376fa8fc5aace 100644 --- a/homeassistant/components/peco/sensor.py +++ b/homeassistant/components/peco/sensor.py @@ -16,13 +16,10 @@ 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, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import PECOCoordinatorData from .const import ATTR_CONTENT, CONF_COUNTY, DOMAIN +from .coordinator import PECOCoordinatorData, PecoOutageCoordinator @dataclass(frozen=True, kw_only=True) @@ -87,9 +84,7 @@ async def async_setup_entry( ) -class PecoSensor( - CoordinatorEntity[DataUpdateCoordinator[PECOCoordinatorData]], SensorEntity -): +class PecoSensor(CoordinatorEntity[PecoOutageCoordinator], SensorEntity): """PECO outage counter sensor.""" entity_description: PECOSensorEntityDescription @@ -100,7 +95,7 @@ def __init__( self, description: PECOSensorEntityDescription, county: str, - coordinator: DataUpdateCoordinator[PECOCoordinatorData], + coordinator: PecoOutageCoordinator, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) diff --git a/homeassistant/components/pencom/switch.py b/homeassistant/components/pencom/switch.py index d9d89494bd93ad..ef988f41da1918 100644 --- a/homeassistant/components/pencom/switch.py +++ b/homeassistant/components/pencom/switch.py @@ -82,18 +82,7 @@ def __init__(self, hub, board, addr, name): self._hub = hub self._board = board self._addr = addr - self._name = name - self._state = None - - @property - def name(self): - """Relay name.""" - return self._name - - @property - def is_on(self): - """Return a relay's state.""" - return self._state + self._attr_name = name def turn_on(self, **kwargs: Any) -> None: """Turn a relay on.""" @@ -105,9 +94,9 @@ def turn_off(self, **kwargs: Any) -> None: def update(self) -> None: """Refresh a relay's state.""" - self._state = self._hub.get(self._board, self._addr) + self._attr_is_on = self._hub.get(self._board, self._addr) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return supported attributes.""" return {"board": self._board, "addr": self._addr} diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index 46e9a121649c3b..d67f45d1540baf 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -403,8 +403,6 @@ async def _handle_user_removed(event: Event) -> None: async def async_reload_yaml(call: ServiceCall) -> None: """Reload YAML.""" conf = await entity_component.async_prepare_reload(skip_reset=True) - if conf is None: - return await yaml_collection.async_load( await filter_yaml_data(hass, conf.get(DOMAIN, [])) ) diff --git a/homeassistant/components/philips_js/light.py b/homeassistant/components/philips_js/light.py index 87e3323a30cd0f..112ee0cd2caa83 100644 --- a/homeassistant/components/philips_js/light.py +++ b/homeassistant/components/philips_js/light.py @@ -135,12 +135,12 @@ def _average_pixels(data): class PhilipsTVLightEntity(PhilipsJsEntity, LightEntity): """Representation of a Philips TV exposing the JointSpace API.""" + _attr_effect: str _attr_translation_key = "ambilight" + _attr_supported_color_modes = {ColorMode.HS} + _attr_supported_features = LightEntityFeature.EFFECT - def __init__( - self, - coordinator: PhilipsTVDataUpdateCoordinator, - ) -> None: + def __init__(self, coordinator: PhilipsTVDataUpdateCoordinator) -> None: """Initialize light.""" self._tv = coordinator.api self._hs = None @@ -149,8 +149,6 @@ def __init__( self._last_selected_effect: AmbilightEffect | None = None super().__init__(coordinator) - self._attr_supported_color_modes = {ColorMode.HS, ColorMode.ONOFF} - self._attr_supported_features = LightEntityFeature.EFFECT self._attr_unique_id = coordinator.unique_id self._update_from_coordinator() @@ -213,10 +211,10 @@ def color_mode(self) -> ColorMode: return ColorMode.ONOFF @property - def is_on(self): + def is_on(self) -> bool: """Return if the light is turned on.""" if self._tv.on: - effect = AmbilightEffect.from_str(self.effect) + effect = AmbilightEffect.from_str(self._attr_effect) return effect.is_on(self._tv.powerstate) return False diff --git a/homeassistant/components/pi_hole/__init__.py b/homeassistant/components/pi_hole/__init__.py index 7d8dbc50866526..0595b01f143ccd 100644 --- a/homeassistant/components/pi_hole/__init__.py +++ b/homeassistant/components/pi_hole/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -from dataclasses import dataclass import logging from typing import Any, Literal @@ -14,23 +13,16 @@ CONF_API_KEY, CONF_HOST, CONF_LOCATION, - CONF_NAME, CONF_SSL, CONF_VERIFY_SSL, Platform, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ( - CONF_STATISTICS_ONLY, - DOMAIN, - MIN_TIME_BETWEEN_UPDATES, - VERSION_6_RESPONSE_TO_5_ERROR, -) +from .const import CONF_STATISTICS_ONLY, DOMAIN +from .coordinator import PiHoleConfigEntry, PiHoleData, PiHoleUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -42,21 +34,9 @@ Platform.UPDATE, ] -type PiHoleConfigEntry = ConfigEntry[PiHoleData] - - -@dataclass -class PiHoleData: - """Runtime data definition.""" - - api: Hole - coordinator: DataUpdateCoordinator[None] - api_version: int - async def async_setup_entry(hass: HomeAssistant, entry: PiHoleConfigEntry) -> bool: """Set up Pi-hole entry.""" - name = entry.data[CONF_NAME] host = entry.data[CONF_HOST] # remove obsolet CONF_STATISTICS_ONLY from entry.data @@ -106,48 +86,7 @@ def update_unique_id( # Once API version 5 is deprecated we should instantiate Hole directly api = api_by_version(hass, dict(entry.data), version) - async def async_update_data() -> None: - """Fetch data from API endpoint.""" - try: - await api.get_data() - await api.get_versions() - if "error" in (response := api.data): - match response["error"]: - case { - "key": key, - "message": message, - "hint": hint, - } if ( - key == VERSION_6_RESPONSE_TO_5_ERROR["key"] - and message == VERSION_6_RESPONSE_TO_5_ERROR["message"] - and hint.startswith("The API is hosted at ") - and "/admin/api" in hint - ): - _LOGGER.warning( - "Pi-hole API v6 returned an error that is expected when using v5 endpoints please re-configure your authentication" - ) - raise ConfigEntryAuthFailed - except HoleError as err: - if str(err) == "Authentication failed: Invalid password": - raise ConfigEntryAuthFailed( - f"Pi-hole {name} at host {host}, reported an invalid password" - ) from err - raise UpdateFailed( - f"Pi-hole {name} at host {host}, update failed with HoleError: {err}" - ) from err - if not isinstance(api.data, dict): - raise ConfigEntryAuthFailed( - f"Pi-hole {name} at host {host}, returned an unexpected response: {api.data}, assuming authentication failed" - ) - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name=name, - update_method=async_update_data, - update_interval=MIN_TIME_BETWEEN_UPDATES, - ) + coordinator = PiHoleUpdateCoordinator(hass, api, entry) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/pi_hole/binary_sensor.py b/homeassistant/components/pi_hole/binary_sensor.py index 049195d01b16b3..eee059b035cea1 100644 --- a/homeassistant/components/pi_hole/binary_sensor.py +++ b/homeassistant/components/pi_hole/binary_sensor.py @@ -15,9 +15,8 @@ from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import PiHoleConfigEntry +from .coordinator import PiHoleConfigEntry, PiHoleUpdateCoordinator from .entity import PiHoleEntity @@ -70,7 +69,7 @@ class PiHoleBinarySensor(PiHoleEntity, BinarySensorEntity): def __init__( self, api: Hole, - coordinator: DataUpdateCoordinator[None], + coordinator: PiHoleUpdateCoordinator, name: str, server_unique_id: str, description: PiHoleBinarySensorEntityDescription, diff --git a/homeassistant/components/pi_hole/coordinator.py b/homeassistant/components/pi_hole/coordinator.py new file mode 100644 index 00000000000000..36cf64f345a938 --- /dev/null +++ b/homeassistant/components/pi_hole/coordinator.py @@ -0,0 +1,89 @@ +"""Coordinator for the Pi-hole integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging + +from hole import HoleV5, HoleV6 +from hole.exceptions import HoleError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import MIN_TIME_BETWEEN_UPDATES, VERSION_6_RESPONSE_TO_5_ERROR + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class PiHoleData: + """Runtime data definition.""" + + api: HoleV5 | HoleV6 + coordinator: PiHoleUpdateCoordinator + api_version: int + + +type PiHoleConfigEntry = ConfigEntry[PiHoleData] + + +class PiHoleUpdateCoordinator(DataUpdateCoordinator[None]): + """Coordinator for Pi-hole data updates.""" + + config_entry: PiHoleConfigEntry + + def __init__( + self, + hass: HomeAssistant, + api: HoleV5 | HoleV6, + config_entry: PiHoleConfigEntry, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=config_entry.data[CONF_NAME], + update_interval=MIN_TIME_BETWEEN_UPDATES, + ) + self._api = api + self._name = config_entry.data[CONF_NAME] + self._host = config_entry.data[CONF_HOST] + + async def _async_update_data(self) -> None: + """Fetch data from the Pi-hole API.""" + try: + await self._api.get_data() + await self._api.get_versions() + if "error" in (response := self._api.data): + match response["error"]: + case { + "key": key, + "message": message, + "hint": hint, + } if ( + key == VERSION_6_RESPONSE_TO_5_ERROR["key"] + and message == VERSION_6_RESPONSE_TO_5_ERROR["message"] + and hint.startswith("The API is hosted at ") + and "/admin/api" in hint + ): + _LOGGER.warning( + "Pi-hole API v6 returned an error that is expected when using v5 endpoints please re-configure your authentication" + ) + raise ConfigEntryAuthFailed + except HoleError as err: + if str(err) == "Authentication failed: Invalid password": + raise ConfigEntryAuthFailed( + f"Pi-hole {self._name} at host {self._host}, reported an invalid password" + ) from err + raise UpdateFailed( + f"Pi-hole {self._name} at host {self._host}, update failed with HoleError: {err}" + ) from err + if not isinstance(self._api.data, dict): + raise ConfigEntryAuthFailed( + f"Pi-hole {self._name} at host {self._host}, returned an unexpected response: {self._api.data}, assuming authentication failed" + ) diff --git a/homeassistant/components/pi_hole/diagnostics.py b/homeassistant/components/pi_hole/diagnostics.py index 115c04c8234669..4b7e7d50cab29d 100644 --- a/homeassistant/components/pi_hole/diagnostics.py +++ b/homeassistant/components/pi_hole/diagnostics.py @@ -8,7 +8,7 @@ from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant -from . import PiHoleConfigEntry +from .coordinator import PiHoleConfigEntry TO_REDACT = {CONF_API_KEY} diff --git a/homeassistant/components/pi_hole/entity.py b/homeassistant/components/pi_hole/entity.py index f29aa81913996d..c1e4b2cc3b5b99 100644 --- a/homeassistant/components/pi_hole/entity.py +++ b/homeassistant/components/pi_hole/entity.py @@ -5,21 +5,19 @@ from hole import Hole from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import PiHoleUpdateCoordinator -class PiHoleEntity(CoordinatorEntity[DataUpdateCoordinator[None]]): +class PiHoleEntity(CoordinatorEntity[PiHoleUpdateCoordinator]): """Representation of a Pi-hole entity.""" def __init__( self, api: Hole, - coordinator: DataUpdateCoordinator[None], + coordinator: PiHoleUpdateCoordinator, name: str, server_unique_id: str, ) -> None: diff --git a/homeassistant/components/pi_hole/sensor.py b/homeassistant/components/pi_hole/sensor.py index 844b03acf7cd01..c77e5f7ed80d42 100644 --- a/homeassistant/components/pi_hole/sensor.py +++ b/homeassistant/components/pi_hole/sensor.py @@ -12,9 +12,8 @@ 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 . import PiHoleConfigEntry +from .coordinator import PiHoleConfigEntry, PiHoleUpdateCoordinator from .entity import PiHoleEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( @@ -148,7 +147,7 @@ class PiHoleSensor(PiHoleEntity, SensorEntity): def __init__( self, api: Hole, - coordinator: DataUpdateCoordinator[None], + coordinator: PiHoleUpdateCoordinator, name: str, server_unique_id: str, description: SensorEntityDescription, diff --git a/homeassistant/components/pi_hole/switch.py b/homeassistant/components/pi_hole/switch.py index 5fdb39bf9ebceb..c643a69fed396e 100644 --- a/homeassistant/components/pi_hole/switch.py +++ b/homeassistant/components/pi_hole/switch.py @@ -14,8 +14,8 @@ from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import PiHoleConfigEntry from .const import SERVICE_DISABLE, SERVICE_DISABLE_ATTR_DURATION +from .coordinator import PiHoleConfigEntry from .entity import PiHoleEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/pi_hole/update.py b/homeassistant/components/pi_hole/update.py index 90fdefd306bf1c..3bf9d3694f1249 100644 --- a/homeassistant/components/pi_hole/update.py +++ b/homeassistant/components/pi_hole/update.py @@ -11,9 +11,8 @@ from homeassistant.const import CONF_NAME, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import PiHoleConfigEntry +from .coordinator import PiHoleConfigEntry, PiHoleUpdateCoordinator from .entity import PiHoleEntity @@ -92,7 +91,7 @@ class PiHoleUpdateEntity(PiHoleEntity, UpdateEntity): def __init__( self, api: Hole, - coordinator: DataUpdateCoordinator[None], + coordinator: PiHoleUpdateCoordinator, name: str, server_unique_id: str, description: PiHoleUpdateEntityDescription, diff --git a/homeassistant/components/pilight/__init__.py b/homeassistant/components/pilight/__init__.py index acea0be702fce8..2e5d7ffe5e88ac 100644 --- a/homeassistant/components/pilight/__init__.py +++ b/homeassistant/components/pilight/__init__.py @@ -20,7 +20,7 @@ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import Event, HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.helpers.typing import ConfigType @@ -37,6 +37,7 @@ DOMAIN = "pilight" EVENT = "pilight_received" +type EVENT_TYPE = Event[dict[str, Any]] # The Pilight code schema depends on the protocol. Thus only require to have # the protocol information. Ensure that protocol is in a list otherwise diff --git a/homeassistant/components/pilight/binary_sensor.py b/homeassistant/components/pilight/binary_sensor.py index 0a94147af70874..93a631e498eae6 100644 --- a/homeassistant/components/pilight/binary_sensor.py +++ b/homeassistant/components/pilight/binary_sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations import datetime +from typing import Any import voluptuous as vol @@ -24,7 +25,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from . import EVENT +from . import EVENT, EVENT_TYPE CONF_VARIABLE = "variable" CONF_RESET_DELAY_SEC = "reset_delay_sec" @@ -46,6 +47,8 @@ } ) +type _PAYLOAD_SET_TYPE = str | int | float + def setup_platform( hass: HomeAssistant, @@ -59,12 +62,12 @@ def setup_platform( [ PilightTriggerSensor( hass=hass, - name=config.get(CONF_NAME), - variable=config.get(CONF_VARIABLE), - payload=config.get(CONF_PAYLOAD), - on_value=config.get(CONF_PAYLOAD_ON), - off_value=config.get(CONF_PAYLOAD_OFF), - rst_dly_sec=config.get(CONF_RESET_DELAY_SEC), + name=config[CONF_NAME], + variable=config[CONF_VARIABLE], + payload=config[CONF_PAYLOAD], + on_value=config[CONF_PAYLOAD_ON], + off_value=config[CONF_PAYLOAD_OFF], + rst_dly_sec=config[CONF_RESET_DELAY_SEC], ) ] ) @@ -73,11 +76,11 @@ def setup_platform( [ PilightBinarySensor( hass=hass, - name=config.get(CONF_NAME), - variable=config.get(CONF_VARIABLE), - payload=config.get(CONF_PAYLOAD), - on_value=config.get(CONF_PAYLOAD_ON), - off_value=config.get(CONF_PAYLOAD_OFF), + name=config[CONF_NAME], + variable=config[CONF_VARIABLE], + payload=config[CONF_PAYLOAD], + on_value=config[CONF_PAYLOAD_ON], + off_value=config[CONF_PAYLOAD_OFF], ) ] ) @@ -86,11 +89,19 @@ def setup_platform( class PilightBinarySensor(BinarySensorEntity): """Representation of a binary sensor that can be updated using Pilight.""" - def __init__(self, hass, name, variable, payload, on_value, off_value): + def __init__( + self, + hass: HomeAssistant, + name: str, + variable: str, + payload: dict[str, Any], + on_value: _PAYLOAD_SET_TYPE, + off_value: _PAYLOAD_SET_TYPE, + ) -> None: """Initialize the sensor.""" - self._state = False + self._attr_is_on = False self._hass = hass - self._name = name + self._attr_name = name self._variable = variable self._payload = payload self._on_value = on_value @@ -98,17 +109,7 @@ def __init__(self, hass, name, variable, payload, on_value, off_value): hass.bus.listen(EVENT, self._handle_code) - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def is_on(self): - """Return True if the binary sensor is on.""" - return self._state - - def _handle_code(self, call): + def _handle_code(self, call: EVENT_TYPE) -> None: """Handle received code by the pilight-daemon. If the code matches the defined payload @@ -128,7 +129,7 @@ def _handle_code(self, call): if self._variable not in call.data: return value = call.data[self._variable] - self._state = value == self._on_value + self._attr_is_on = value == self._on_value self.schedule_update_ha_state() @@ -136,38 +137,35 @@ class PilightTriggerSensor(BinarySensorEntity): """Representation of a binary sensor that can be updated using Pilight.""" def __init__( - self, hass, name, variable, payload, on_value, off_value, rst_dly_sec=30 - ): + self, + hass: HomeAssistant, + name: str, + variable: str, + payload: dict[str, Any], + on_value: _PAYLOAD_SET_TYPE, + off_value: _PAYLOAD_SET_TYPE, + rst_dly_sec: int, + ) -> None: """Initialize the sensor.""" - self._state = False + self._attr_is_on = False self._hass = hass - self._name = name + self._attr_name = name self._variable = variable self._payload = payload self._on_value = on_value self._off_value = off_value self._reset_delay_sec = rst_dly_sec - self._delay_after = None + self._delay_after: datetime.datetime | None = None self._hass = hass hass.bus.listen(EVENT, self._handle_code) - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def is_on(self): - """Return True if the binary sensor is on.""" - return self._state - - def _reset_state(self, call): - self._state = False + def _reset_state(self, _: datetime.datetime) -> None: + self._attr_is_on = False self._delay_after = None self.schedule_update_ha_state() - def _handle_code(self, call): + def _handle_code(self, call: EVENT_TYPE) -> None: """Handle received code by the pilight-daemon. If the code matches the defined payload @@ -187,7 +185,7 @@ def _handle_code(self, call): if self._variable not in call.data: return value = call.data[self._variable] - self._state = value == self._on_value + self._attr_is_on = value == self._on_value if self._delay_after is None: self._delay_after = dt_util.utcnow() + datetime.timedelta( seconds=self._reset_delay_sec diff --git a/homeassistant/components/pilight/entity.py b/homeassistant/components/pilight/entity.py index fbfa5cfb5e1deb..1529f7551fa2c3 100644 --- a/homeassistant/components/pilight/entity.py +++ b/homeassistant/components/pilight/entity.py @@ -1,5 +1,7 @@ """Base class for pilight.""" +from typing import Any + import voluptuous as vol from homeassistant.const import ( @@ -10,8 +12,10 @@ STATE_OFF, STATE_ON, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import ConfigType from . import DOMAIN, EVENT, SERVICE_NAME from .const import ( @@ -57,21 +61,22 @@ class PilightBaseDevice(RestoreEntity): """Base class for pilight switches and lights.""" + _attr_assumed_state = True _attr_should_poll = False - def __init__(self, hass, name, config): + def __init__(self, hass: HomeAssistant, name: str, config: ConfigType) -> None: """Initialize a device.""" self._hass = hass - self._name = config.get(CONF_NAME, name) - self._is_on = False + self._attr_name = config.get(CONF_NAME, name) + self._attr_is_on: bool | None = False self._code_on = config.get(CONF_ON_CODE) self._code_off = config.get(CONF_OFF_CODE) code_on_receive = config.get(CONF_ON_CODE_RECEIVE, []) code_off_receive = config.get(CONF_OFF_CODE_RECEIVE, []) - self._code_on_receive = [] - self._code_off_receive = [] + self._code_on_receive: list[_ReceiveHandle] = [] + self._code_off_receive: list[_ReceiveHandle] = [] for code_list, conf in ( (self._code_on_receive, code_on_receive), @@ -84,30 +89,15 @@ def __init__(self, hass, name, config): if any(self._code_on_receive) or any(self._code_off_receive): hass.bus.listen(EVENT, self._handle_code) - self._brightness = 255 + self._brightness: int | None = 255 async def async_added_to_hass(self) -> None: """Call when entity about to be added to hass.""" await super().async_added_to_hass() if state := await self.async_get_last_state(): - self._is_on = state.state == STATE_ON + self._attr_is_on = state.state == STATE_ON self._brightness = state.attributes.get("brightness") - @property - def name(self): - """Get the name of the switch.""" - return self._name - - @property - def assumed_state(self) -> bool: - """Return True if unable to access real state of the entity.""" - return True - - @property - def is_on(self): - """Return true if switch is on.""" - return self._is_on - def _handle_code(self, call): """Check if received code by the pilight-daemon. @@ -148,7 +138,7 @@ def set_state(self, turn_on, send_code=True, dimlevel=None): DOMAIN, SERVICE_NAME, self._code_off, blocking=True ) - self._is_on = turn_on + self._attr_is_on = turn_on self.schedule_update_ha_state() def turn_on(self, **kwargs): @@ -161,18 +151,18 @@ def turn_off(self, **kwargs): class _ReceiveHandle: - def __init__(self, config, echo): + def __init__(self, config: dict[str, Any], echo: bool) -> None: """Initialize the handle.""" self.config_items = config.items() self.echo = echo - def match(self, code): + def match(self, code: dict[str, Any]) -> bool: """Test if the received code matches the configured values. The received values have to be a subset of the configured options. """ return self.config_items <= code.items() - def run(self, switch, turn_on): + def run(self, switch: PilightBaseDevice, turn_on: bool) -> None: """Change the state of the switch.""" switch.set_state(turn_on=turn_on, send_code=self.echo) diff --git a/homeassistant/components/pilight/light.py b/homeassistant/components/pilight/light.py index 9e1ecbf59d4638..3a647dad09353d 100644 --- a/homeassistant/components/pilight/light.py +++ b/homeassistant/components/pilight/light.py @@ -55,14 +55,14 @@ class PilightLight(PilightBaseDevice, LightEntity): _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} - def __init__(self, hass, name, config): + def __init__(self, hass: HomeAssistant, name: str, config: ConfigType) -> None: """Initialize a switch.""" super().__init__(hass, name, config) - self._dimlevel_min = config.get(CONF_DIMLEVEL_MIN) - self._dimlevel_max = config.get(CONF_DIMLEVEL_MAX) + self._dimlevel_min: int = config[CONF_DIMLEVEL_MIN] + self._dimlevel_max: int = config[CONF_DIMLEVEL_MAX] @property - def brightness(self): + def brightness(self) -> int | None: """Return the brightness.""" return self._brightness diff --git a/homeassistant/components/pilight/sensor.py b/homeassistant/components/pilight/sensor.py index 532681e2b93c7c..60ded6aad87a5f 100644 --- a/homeassistant/components/pilight/sensor.py +++ b/homeassistant/components/pilight/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import Any import voluptuous as vol @@ -16,7 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import EVENT +from . import EVENT, EVENT_TYPE _LOGGER = logging.getLogger(__name__) @@ -44,9 +45,9 @@ def setup_platform( [ PilightSensor( hass=hass, - name=config.get(CONF_NAME), - variable=config.get(CONF_VARIABLE), - payload=config.get(CONF_PAYLOAD), + name=config[CONF_NAME], + variable=config[CONF_VARIABLE], + payload=config[CONF_PAYLOAD], unit_of_measurement=config.get(CONF_UNIT_OF_MEASUREMENT), ) ] @@ -58,33 +59,24 @@ class PilightSensor(SensorEntity): _attr_should_poll = False - def __init__(self, hass, name, variable, payload, unit_of_measurement): + def __init__( + self, + hass: HomeAssistant, + name: str, + variable: str, + payload: dict[str, Any], + unit_of_measurement: str | None, + ) -> None: """Initialize the sensor.""" - self._state = None self._hass = hass - self._name = name + self._attr_name = name self._variable = variable self._payload = payload - self._unit_of_measurement = unit_of_measurement + self._attr_native_unit_of_measurement = unit_of_measurement hass.bus.listen(EVENT, self._handle_code) - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_unit_of_measurement(self): - """Return the unit this state is expressed in.""" - return self._unit_of_measurement - - @property - def native_value(self): - """Return the state of the entity.""" - return self._state - - def _handle_code(self, call): + def _handle_code(self, call: EVENT_TYPE) -> None: """Handle received code by the pilight-daemon. If the code matches the defined payload @@ -96,7 +88,7 @@ def _handle_code(self, call): if self._payload.items() <= call.data.items(): try: value = call.data[self._variable] - self._state = value + self._attr_native_value = value self.schedule_update_ha_state() except KeyError: _LOGGER.error( diff --git a/homeassistant/components/plaato/__init__.py b/homeassistant/components/plaato/__init__.py index 14e757d46233d2..490bc094aaaca8 100644 --- a/homeassistant/components/plaato/__init__.py +++ b/homeassistant/components/plaato/__init__.py @@ -22,7 +22,7 @@ import voluptuous as vol from homeassistant.components import webhook -from homeassistant.components.sensor import DOMAIN as SENSOR +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_SCAN_INTERVAL, @@ -57,7 +57,7 @@ DEPENDENCIES = ["webhook"] SENSOR_UPDATE = f"{DOMAIN}_sensor_update" -SENSOR_DATA_KEY = f"{DOMAIN}.{SENSOR}" +SENSOR_DATA_KEY = f"{DOMAIN}.{SENSOR_DOMAIN}" WEBHOOK_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/plaato/binary_sensor.py b/homeassistant/components/plaato/binary_sensor.py index b71673aa1fdcaa..de574738d8d9b1 100644 --- a/homeassistant/components/plaato/binary_sensor.py +++ b/homeassistant/components/plaato/binary_sensor.py @@ -49,7 +49,7 @@ def __init__(self, data, sensor_type, coordinator=None) -> None: self._attr_device_class = BinarySensorDeviceClass.OPENING @property - def is_on(self): + def is_on(self) -> bool: """Return true if the binary sensor is on.""" if self._coordinator is not None: return self._coordinator.data.binary_sensors.get(self._sensor_type) diff --git a/homeassistant/components/plant/__init__.py b/homeassistant/components/plant/__init__.py index 27993a93779916..77c1c2b7b6ce85 100644 --- a/homeassistant/components/plant/__init__.py +++ b/homeassistant/components/plant/__init__.py @@ -8,6 +8,7 @@ from contextlib import suppress from datetime import datetime, timedelta import logging +from typing import Any import voluptuous as vol @@ -345,7 +346,7 @@ def state(self): return self._state @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the attributes of the entity. Provide the individual measurements from the diff --git a/homeassistant/components/playstation_network/manifest.json b/homeassistant/components/playstation_network/manifest.json index 419f572c9a75d0..4b92d3ddef0ba7 100644 --- a/homeassistant/components/playstation_network/manifest.json +++ b/homeassistant/components/playstation_network/manifest.json @@ -90,5 +90,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["PSNAWP==3.0.1", "pyrate-limiter==3.9.0"] + "requirements": ["PSNAWP==3.0.3", "pyrate-limiter==4.0.2"] } diff --git a/homeassistant/components/playstation_network/sensor.py b/homeassistant/components/playstation_network/sensor.py index 86aab0feaf6250..4e91bf2f1bbf3b 100644 --- a/homeassistant/components/playstation_network/sensor.py +++ b/homeassistant/components/playstation_network/sensor.py @@ -11,6 +11,7 @@ SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant @@ -61,6 +62,7 @@ class PlaystationNetworkSensor(StrEnum): value_fn=( lambda psn: psn.trophy_summary.trophy_level if psn.trophy_summary else None ), + state_class=SensorStateClass.MEASUREMENT, ), PlaystationNetworkSensorEntityDescription( key=PlaystationNetworkSensor.TROPHY_LEVEL_PROGRESS, @@ -69,6 +71,7 @@ class PlaystationNetworkSensor(StrEnum): lambda psn: psn.trophy_summary.progress if psn.trophy_summary else None ), native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, ), PlaystationNetworkSensorEntityDescription( key=PlaystationNetworkSensor.EARNED_TROPHIES_PLATINUM, @@ -80,6 +83,7 @@ class PlaystationNetworkSensor(StrEnum): else None ) ), + state_class=SensorStateClass.MEASUREMENT, ), PlaystationNetworkSensorEntityDescription( key=PlaystationNetworkSensor.EARNED_TROPHIES_GOLD, @@ -89,6 +93,7 @@ class PlaystationNetworkSensor(StrEnum): psn.trophy_summary.earned_trophies.gold if psn.trophy_summary else None ) ), + state_class=SensorStateClass.MEASUREMENT, ), PlaystationNetworkSensorEntityDescription( key=PlaystationNetworkSensor.EARNED_TROPHIES_SILVER, @@ -100,6 +105,7 @@ class PlaystationNetworkSensor(StrEnum): else None ) ), + state_class=SensorStateClass.MEASUREMENT, ), PlaystationNetworkSensorEntityDescription( key=PlaystationNetworkSensor.EARNED_TROPHIES_BRONZE, @@ -111,6 +117,7 @@ class PlaystationNetworkSensor(StrEnum): else None ) ), + state_class=SensorStateClass.MEASUREMENT, ), PlaystationNetworkSensorEntityDescription( key=PlaystationNetworkSensor.ONLINE_ID, diff --git a/homeassistant/components/plex/cast.py b/homeassistant/components/plex/cast.py index bf68be202929aa..b95e836329a3e4 100644 --- a/homeassistant/components/plex/cast.py +++ b/homeassistant/components/plex/cast.py @@ -23,7 +23,7 @@ async def async_get_media_browser_root_object( media_class=MediaClass.APP, media_content_id="", media_content_type="plex", - thumbnail="https://brands.home-assistant.io/_/plex/logo.png", + thumbnail="/api/brands/integration/plex/logo.png", can_play=False, can_expand=True, ) diff --git a/homeassistant/components/plex/media_browser.py b/homeassistant/components/plex/media_browser.py index 87e9f47af66471..74beee479f0597 100644 --- a/homeassistant/components/plex/media_browser.py +++ b/homeassistant/components/plex/media_browser.py @@ -94,7 +94,7 @@ def server_payload(): can_expand=True, children=[], children_media_class=MediaClass.DIRECTORY, - thumbnail="https://brands.home-assistant.io/_/plex/logo.png", + thumbnail="/api/brands/integration/plex/logo.png", ) if platform != "sonos": server_info.children.append( diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index ed96adeff8ab7a..0c74714cb4e454 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -500,7 +500,7 @@ def play_media( ) from exc @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the scene state attributes.""" attributes = {} for attr in ( diff --git a/homeassistant/components/plex/sensor.py b/homeassistant/components/plex/sensor.py index 66e513dd83aa48..87af46f198d6a0 100644 --- a/homeassistant/components/plex/sensor.py +++ b/homeassistant/components/plex/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import Any from plexapi.exceptions import NotFound import requests.exceptions @@ -110,7 +111,7 @@ async def _async_refresh_sensor(self) -> None: self.async_write_ha_state() @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return self._server.sensor_attributes diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 9f712ad67b36a7..ac33f04215fe74 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import asdict, dataclass from typing import Any from homeassistant.components.climate import ( @@ -38,10 +38,7 @@ class PlugwiseClimateExtraStoredData(ExtraStoredData): def as_dict(self) -> dict[str, Any]: """Return a dict representation of the text data.""" - return { - "last_active_schedule": self.last_active_schedule, - "previous_action_mode": self.previous_action_mode, - } + return asdict(self) @classmethod def from_dict(cls, restored: dict[str, Any]) -> PlugwiseClimateExtraStoredData: @@ -102,7 +99,9 @@ async def async_added_to_hass(self) -> None: extra_data.as_dict() ) self._last_active_schedule = plugwise_extra_data.last_active_schedule - self._previous_action_mode = plugwise_extra_data.previous_action_mode + self._previous_action_mode = ( + plugwise_extra_data.previous_action_mode or HVACAction.HEATING.value + ) def __init__( self, @@ -202,11 +201,10 @@ def hvac_modes(self) -> list[HVACMode]: if self.coordinator.api.cooling_present: if "regulation_modes" in self._gateway_data: - selected = self._gateway_data.get("select_regulation_mode") - if selected == HVACAction.COOLING.value: - hvac_modes.append(HVACMode.COOL) - if selected == HVACAction.HEATING.value: + if "heating" in self._gateway_data["regulation_modes"]: hvac_modes.append(HVACMode.HEAT) + if "cooling" in self._gateway_data["regulation_modes"]: + hvac_modes.append(HVACMode.COOL) else: hvac_modes.append(HVACMode.HEAT_COOL) else: @@ -253,40 +251,75 @@ async def async_set_temperature(self, **kwargs: Any) -> None: await self.coordinator.api.set_temperature(self._location, data) + def _regulation_mode_for_hvac(self, hvac_mode: HVACMode) -> str | None: + """Return the API regulation value for a manual HVAC mode, or None.""" + if hvac_mode == HVACMode.HEAT: + return HVACAction.HEATING.value + if hvac_mode == HVACMode.COOL: + return HVACAction.COOLING.value + return None + @plugwise_command async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set the hvac mode.""" + """Set the HVAC mode (off, heat, cool, heat_cool, or auto/schedule).""" if hvac_mode == self.hvac_mode: return + api = self.coordinator.api + current_schedule = self.device.get("select_schedule") + + # OFF: single API call if hvac_mode == HVACMode.OFF: - await self.coordinator.api.set_regulation_mode(hvac_mode.value) - else: - current = self.device.get("select_schedule") - desired = current - - # Capture the last valid schedule - if desired and desired != "off": - self._last_active_schedule = desired - elif desired == "off": - desired = self._last_active_schedule - - # Enabling HVACMode.AUTO requires a previously set schedule for saving and restoring - if hvac_mode == HVACMode.AUTO and not desired: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key=ERROR_NO_SCHEDULE, - ) + await api.set_regulation_mode(hvac_mode.value) + return - await self.coordinator.api.set_schedule_state( - self._location, - STATE_ON if hvac_mode == HVACMode.AUTO else STATE_OFF, - desired, + # Manual mode (heat/cool/heat_cool) without a schedule: set regulation only + if ( + current_schedule is None + and hvac_mode != HVACMode.AUTO + and ( + regulation := self._regulation_mode_for_hvac(hvac_mode) + or self._previous_action_mode ) - if self.hvac_mode == HVACMode.OFF and self._previous_action_mode: - await self.coordinator.api.set_regulation_mode( - self._previous_action_mode + ): + await api.set_regulation_mode(regulation) + return + + # Manual mode: ensure regulation and turn off schedule when needed + if hvac_mode in (HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL): + regulation = self._regulation_mode_for_hvac(hvac_mode) or ( + self._previous_action_mode + if self.hvac_mode in (HVACMode.HEAT_COOL, HVACMode.OFF) + else None + ) + if regulation: + await api.set_regulation_mode(regulation) + + if ( + self.hvac_mode == HVACMode.OFF and current_schedule not in (None, "off") + ) or (self.hvac_mode == HVACMode.AUTO and current_schedule is not None): + await api.set_schedule_state( + self._location, STATE_OFF, current_schedule ) + return + + # AUTO: restore schedule and regulation + desired_schedule = current_schedule + if desired_schedule and desired_schedule != "off": + self._last_active_schedule = desired_schedule + elif desired_schedule == "off": + desired_schedule = self._last_active_schedule + + if not desired_schedule: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=ERROR_NO_SCHEDULE, + ) + + if self._previous_action_mode: + if self.hvac_mode == HVACMode.OFF: + await api.set_regulation_mode(self._previous_action_mode) + await api.set_schedule_state(self._location, STATE_ON, desired_schedule) @plugwise_command async def async_set_preset_mode(self, preset_mode: str) -> None: diff --git a/homeassistant/components/plugwise/coordinator.py b/homeassistant/components/plugwise/coordinator.py index 566b6f7584095d..b0a28c5f6165b5 100644 --- a/homeassistant/components/plugwise/coordinator.py +++ b/homeassistant/components/plugwise/coordinator.py @@ -36,11 +36,6 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData]]): """Class to manage fetching Plugwise data from single endpoint.""" - _connected: bool = False - _current_devices: set[str] - _stored_devices: set[str] - new_devices: set[str] - config_entry: PlugwiseConfigEntry def __init__(self, hass: HomeAssistant, config_entry: PlugwiseConfigEntry) -> None: @@ -68,9 +63,11 @@ def __init__(self, hass: HomeAssistant, config_entry: PlugwiseConfigEntry) -> No port=self.config_entry.data.get(CONF_PORT, DEFAULT_PORT), websession=async_get_clientsession(hass, verify_ssl=False), ) - self._current_devices = set() - self._stored_devices = set() - self.new_devices = set() + self._connected: bool = False + self._current_devices: set[str] = set() + self._firmware_list: dict[str, str | None] = {} + self._stored_devices: set[str] = set() + self.new_devices: set[str] = set() async def _connect(self) -> None: """Connect to the Plugwise Smile. @@ -132,49 +129,77 @@ async def _async_update_data(self) -> dict[str, GwEntityData]: translation_key="unsupported_firmware", ) from err - self._async_add_remove_devices(data) + self._add_remove_devices(data) + self._update_device_firmware(data) return data - def _async_add_remove_devices(self, data: dict[str, GwEntityData]) -> None: + def _add_remove_devices(self, data: dict[str, GwEntityData]) -> None: """Add new Plugwise devices, remove non-existing devices.""" set_of_data = set(data) # Check for new or removed devices, # 'new_devices' contains all devices present in 'data' at init ('self._current_devices' is empty) # this is required for the proper initialization of all the present platform entities. self.new_devices = set_of_data - self._current_devices + for device_id in self.new_devices: + self._firmware_list.setdefault(device_id, data[device_id].get("firmware")) + current_devices = ( self._stored_devices if not self._current_devices else self._current_devices ) self._current_devices = set_of_data - if current_devices - set_of_data: # device(s) to remove - self._async_remove_devices(data) + if removed_devices := (current_devices - set_of_data): # device(s) to remove + self._remove_devices(removed_devices) - def _async_remove_devices(self, data: dict[str, GwEntityData]) -> None: + def _remove_devices(self, removed_devices: set[str]) -> None: """Clean registries when removed devices found.""" device_reg = dr.async_get(self.hass) - device_list = dr.async_entries_for_config_entry( - device_reg, self.config_entry.entry_id - ) - - # First find the Plugwise via_device - gateway_device = device_reg.async_get_device({(DOMAIN, self.api.gateway_id)}) - assert gateway_device is not None - via_device_id = gateway_device.id - # Then remove the connected orphaned device(s) - for device_entry in device_list: - for identifier in device_entry.identifiers: - if ( - identifier[0] == DOMAIN - and device_entry.via_device_id == via_device_id - and identifier[1] not in data - ): - device_reg.async_update_device( - device_entry.id, - remove_config_entry_id=self.config_entry.entry_id, - ) - LOGGER.debug( - "Removed %s device/zone %s %s from device_registry", - DOMAIN, - device_entry.model, - identifier[1], - ) + for device_id in removed_devices: + if ( + device_entry := device_reg.async_get_device({(DOMAIN, device_id)}) + ) is not None: + device_reg.async_update_device( + device_entry.id, remove_config_entry_id=self.config_entry.entry_id + ) + LOGGER.debug( + "%s %s %s removed from device_registry", + DOMAIN, + device_entry.model, + device_id, + ) + + self._firmware_list.pop(device_id, None) + + def _update_device_firmware(self, data: dict[str, GwEntityData]) -> None: + """Detect firmware changes and update the device registry.""" + for device_id, device in data.items(): + # Only update firmware when the key is present and not None, to avoid + # wiping stored firmware on partial or transient updates. + if "firmware" not in device: + continue + new_firmware = device.get("firmware") + if new_firmware is None: + continue + if ( + device_id in self._firmware_list + and new_firmware != self._firmware_list[device_id] + ): + updated = self._update_firmware_in_dr(device_id, new_firmware) + if updated: + self._firmware_list[device_id] = new_firmware + + def _update_firmware_in_dr(self, device_id: str, firmware: str | None) -> bool: + """Update device sw_version in device_registry.""" + device_reg = dr.async_get(self.hass) + if ( + device_entry := device_reg.async_get_device({(DOMAIN, device_id)}) + ) is not None: + device_reg.async_update_device(device_entry.id, sw_version=firmware) + LOGGER.debug( + "Firmware in device_registry updated for %s %s %s", + DOMAIN, + device_entry.model, + device_id, + ) + return True + + return False # pragma: no cover diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index d5dbeb32b0292a..b17edb50835e2c 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["plugwise"], "quality_scale": "platinum", - "requirements": ["plugwise==1.11.2"], + "requirements": ["plugwise==1.11.3"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/point/entity.py b/homeassistant/components/point/entity.py index b6718d7fd2d355..bdf506d8d1f2d3 100644 --- a/homeassistant/components/point/entity.py +++ b/homeassistant/components/point/entity.py @@ -1,6 +1,7 @@ """Support for Minut Point.""" import logging +from typing import Any from pypoint import Device, PointSession @@ -56,7 +57,7 @@ def device(self) -> Device: return self.client.device(self.device_id) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return status of device.""" attrs = self.device.device_status attrs["last_heard_from"] = as_local( diff --git a/homeassistant/components/pooldose/manifest.json b/homeassistant/components/pooldose/manifest.json index 87610c605c2ee9..66f95e7c82f561 100644 --- a/homeassistant/components/pooldose/manifest.json +++ b/homeassistant/components/pooldose/manifest.json @@ -12,5 +12,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["python-pooldose==0.8.2"] + "requirements": ["python-pooldose==0.8.6"] } diff --git a/homeassistant/components/portainer/__init__.py b/homeassistant/components/portainer/__init__.py index a63fae46d4a0bc..6e166ffd7b9681 100644 --- a/homeassistant/components/portainer/__init__.py +++ b/homeassistant/components/portainer/__init__.py @@ -5,6 +5,7 @@ import logging from pyportainer import Portainer +from pyportainer.exceptions import PortainerError from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -19,18 +20,19 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession import homeassistant.helpers.config_validation as cv import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.device_registry import DeviceEntry import homeassistant.helpers.entity_registry as er from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN +from .const import API_MAX_RETRIES, DOMAIN from .coordinator import PortainerCoordinator from .services import async_setup_services _PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.SENSOR, Platform.SWITCH, - Platform.BUTTON, ] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -49,6 +51,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> session=async_create_clientsession( hass=hass, verify_ssl=entry.data[CONF_VERIFY_SSL] ), + request_timeout=30, + max_retries=API_MAX_RETRIES, ) coordinator = PortainerCoordinator(hass, entry, client) @@ -136,4 +140,47 @@ async def async_migrate_entry(hass: HomeAssistant, entry: PortainerConfigEntry) hass.config_entries.async_update_entry(entry=entry, version=4) + if entry.version < 5: + client = Portainer( + api_url=entry.data[CONF_URL], + api_key=entry.data[CONF_API_TOKEN], + session=async_create_clientsession( + hass=hass, verify_ssl=entry.data[CONF_VERIFY_SSL] + ), + ) + try: + system_status = await client.portainer_system_status() + except PortainerError: + _LOGGER.exception("Failed to fetch instance ID during migration") + return False + + hass.config_entries.async_update_entry( + entry=entry, + unique_id=system_status.instance_id, + version=5, + ) + return True + + +async def async_remove_config_entry_device( + hass: HomeAssistant, + entry: PortainerConfigEntry, + device: DeviceEntry, +) -> bool: + """Remove a config entry from a device.""" + coordinator = entry.runtime_data + valid_identifiers: set[tuple[str, str]] = set() + + # The Portainer integration creates devices for both endpoints and containers. That's why we're doing it double + valid_identifiers.update( + (DOMAIN, f"{entry.entry_id}_{endpoint_id}") for endpoint_id in coordinator.data + ) + + valid_identifiers.update( + (DOMAIN, f"{entry.entry_id}_{container_name}") + for endpoint in coordinator.data.values() + for container_name in endpoint.containers + ) + + return not device.identifiers.intersection(valid_identifiers) diff --git a/homeassistant/components/portainer/binary_sensor.py b/homeassistant/components/portainer/binary_sensor.py index 937b23b0b18625..787656b0268047 100644 --- a/homeassistant/components/portainer/binary_sensor.py +++ b/homeassistant/components/portainer/binary_sensor.py @@ -15,15 +15,17 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import PortainerConfigEntry -from .const import CONTAINER_STATE_RUNNING -from .coordinator import PortainerContainerData, PortainerCoordinator +from .const import ContainerState, EndpointStatus, StackStatus +from .coordinator import PortainerContainerData from .entity import ( PortainerContainerEntity, PortainerCoordinatorData, PortainerEndpointEntity, + PortainerStackData, + PortainerStackEntity, ) -PARALLEL_UPDATES = 1 +PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) @@ -40,11 +42,18 @@ class PortainerEndpointBinarySensorEntityDescription(BinarySensorEntityDescripti state_fn: Callable[[PortainerCoordinatorData], bool | None] +@dataclass(frozen=True, kw_only=True) +class PortainerStackBinarySensorEntityDescription(BinarySensorEntityDescription): + """Class to hold Portainer stack binary sensor description.""" + + state_fn: Callable[[PortainerStackData], bool | None] + + CONTAINER_SENSORS: tuple[PortainerContainerBinarySensorEntityDescription, ...] = ( PortainerContainerBinarySensorEntityDescription( key="status", translation_key="status", - state_fn=lambda data: data.container.state == CONTAINER_STATE_RUNNING, + state_fn=lambda data: data.container.state == ContainerState.RUNNING, device_class=BinarySensorDeviceClass.RUNNING, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -54,7 +63,17 @@ class PortainerEndpointBinarySensorEntityDescription(BinarySensorEntityDescripti PortainerEndpointBinarySensorEntityDescription( key="status", translation_key="status", - state_fn=lambda data: data.endpoint.status == 1, # 1 = Running | 2 = Stopped + state_fn=lambda data: data.endpoint.status == EndpointStatus.UP, + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + +STACK_SENSORS: tuple[PortainerStackBinarySensorEntityDescription, ...] = ( + PortainerStackBinarySensorEntityDescription( + key="stack_status", + translation_key="status", + state_fn=lambda data: data.stack.status == StackStatus.ACTIVE, device_class=BinarySensorDeviceClass.RUNNING, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -98,9 +117,24 @@ def _async_add_new_containers( if entity_description.state_fn(container) ) + def _async_add_new_stacks( + stacks: list[tuple[PortainerCoordinatorData, PortainerStackData]], + ) -> None: + """Add new stack sensors.""" + async_add_entities( + PortainerStackSensor( + coordinator, + entity_description, + stack, + endpoint, + ) + for (endpoint, stack) in stacks + for entity_description in STACK_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) _async_add_new_endpoints( [ endpoint @@ -115,6 +149,13 @@ def _async_add_new_containers( for container in endpoint.containers.values() ] ) + _async_add_new_stacks( + [ + (endpoint, stack) + for endpoint in coordinator.data.values() + for stack in endpoint.stacks.values() + ] + ) class PortainerEndpointSensor(PortainerEndpointEntity, BinarySensorEntity): @@ -122,18 +163,6 @@ class PortainerEndpointSensor(PortainerEndpointEntity, BinarySensorEntity): entity_description: PortainerEndpointBinarySensorEntityDescription - def __init__( - self, - coordinator: PortainerCoordinator, - entity_description: PortainerEndpointBinarySensorEntityDescription, - device_info: PortainerCoordinatorData, - ) -> None: - """Initialize Portainer endpoint binary sensor entity.""" - self.entity_description = entity_description - super().__init__(device_info, coordinator) - - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_info.id}_{entity_description.key}" - @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" @@ -145,20 +174,18 @@ class PortainerContainerSensor(PortainerContainerEntity, BinarySensorEntity): entity_description: PortainerContainerBinarySensorEntityDescription - def __init__( - self, - coordinator: PortainerCoordinator, - entity_description: PortainerContainerBinarySensorEntityDescription, - device_info: PortainerContainerData, - via_device: PortainerCoordinatorData, - ) -> None: - """Initialize the Portainer container sensor.""" - self.entity_description = entity_description - super().__init__(device_info, coordinator, via_device) + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.entity_description.state_fn(self.container_data) + - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}" +class PortainerStackSensor(PortainerStackEntity, BinarySensorEntity): + """Representation of a Portainer stack sensor.""" + + entity_description: PortainerStackBinarySensorEntityDescription @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" - return self.entity_description.state_fn(self.container_data) + return self.entity_description.state_fn(self.stack_data) diff --git a/homeassistant/components/portainer/button.py b/homeassistant/components/portainer/button.py index 9b9e59e311de71..daa17452379736 100644 --- a/homeassistant/components/portainer/button.py +++ b/homeassistant/components/portainer/button.py @@ -74,6 +74,26 @@ class PortainerButtonDescription(ButtonEntityDescription): ) ), ), + PortainerButtonDescription( + key="pause", + translation_key="pause_container", + entity_category=EntityCategory.CONFIG, + press_action=( + lambda portainer, endpoint_id, container_id: portainer.pause_container( + endpoint_id, container_id + ) + ), + ), + PortainerButtonDescription( + key="resume", + translation_key="resume_container", + entity_category=EntityCategory.CONFIG, + press_action=( + lambda portainer, endpoint_id, container_id: portainer.unpause_container( + endpoint_id, container_id + ) + ), + ), ) @@ -167,18 +187,6 @@ class PortainerEndpointButton(PortainerEndpointEntity, PortainerBaseButton): entity_description: PortainerButtonDescription - def __init__( - self, - coordinator: PortainerCoordinator, - entity_description: PortainerButtonDescription, - device_info: PortainerCoordinatorData, - ) -> None: - """Initialize the Portainer endpoint button entity.""" - self.entity_description = entity_description - super().__init__(device_info, coordinator) - - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_info.id}_{entity_description.key}" - async def _async_press_call(self) -> None: """Call the endpoint button press action.""" await self.entity_description.press_action( @@ -191,19 +199,6 @@ class PortainerContainerButton(PortainerContainerEntity, PortainerBaseButton): entity_description: PortainerButtonDescription - def __init__( - self, - coordinator: PortainerCoordinator, - entity_description: PortainerButtonDescription, - device_info: PortainerContainerData, - via_device: PortainerCoordinatorData, - ) -> None: - """Initialize the Portainer button entity.""" - self.entity_description = entity_description - super().__init__(device_info, coordinator, via_device) - - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}" - async def _async_press_call(self) -> None: """Call the container button press action.""" await self.entity_description.press_action( diff --git a/homeassistant/components/portainer/config_flow.py b/homeassistant/components/portainer/config_flow.py index 9e8b3f14032cd7..b94f2943a5b952 100644 --- a/homeassistant/components/portainer/config_flow.py +++ b/homeassistant/components/portainer/config_flow.py @@ -12,6 +12,7 @@ PortainerConnectionError, PortainerTimeoutError, ) +from pyportainer.models.portainer import PortainerSystemStatus import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -32,7 +33,9 @@ ) -async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: +async def _validate_input( + hass: HomeAssistant, data: dict[str, Any] +) -> PortainerSystemStatus: """Validate the user input allows us to connect.""" client = Portainer( @@ -41,7 +44,7 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: session=async_get_clientsession(hass=hass, verify_ssl=data[CONF_VERIFY_SSL]), ) try: - await client.get_endpoints() + system_status = await client.portainer_system_status() except PortainerAuthenticationError: raise InvalidAuth from None except PortainerConnectionError as err: @@ -50,12 +53,13 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: raise PortainerTimeout from err _LOGGER.debug("Connected to Portainer API: %s", data[CONF_URL]) + return system_status class PortainerConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Portainer.""" - VERSION = 4 + VERSION = 5 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -63,9 +67,8 @@ async def async_step_user( """Handle the initial step.""" errors: dict[str, str] = {} if user_input is not None: - self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) try: - await _validate_input(self.hass, user_input) + system_status = await _validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: @@ -76,7 +79,7 @@ async def async_step_user( _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(user_input[CONF_API_TOKEN]) + await self.async_set_unique_id(system_status.instance_id) self._abort_if_unique_id_configured() return self.async_create_entry( title=user_input[CONF_URL], data=user_input @@ -142,7 +145,7 @@ async def async_step_reconfigure( if user_input: try: - await _validate_input( + system_status = await _validate_input( self.hass, data={ **reconf_entry.data, @@ -159,8 +162,8 @@ async def async_step_reconfigure( _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(user_input[CONF_API_TOKEN]) - self._abort_if_unique_id_configured() + await self.async_set_unique_id(system_status.instance_id) + self._abort_if_unique_id_mismatch() return self.async_update_reload_and_abort( reconf_entry, data_updates={ diff --git a/homeassistant/components/portainer/const.py b/homeassistant/components/portainer/const.py index bc12cb29e8a160..8c1f1fa9d094a1 100644 --- a/homeassistant/components/portainer/const.py +++ b/homeassistant/components/portainer/const.py @@ -1,9 +1,36 @@ """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 + -ENDPOINT_STATUS_DOWN = 2 +class StackType(IntEnum): + """Portainer stack type.""" -CONTAINER_STATE_RUNNING = "running" + SWARM = 1 + COMPOSE = 2 + KUBERNETES = 3 diff --git a/homeassistant/components/portainer/coordinator.py b/homeassistant/components/portainer/coordinator.py index c53d4caba0c8f7..1b84409dbde0d1 100644 --- a/homeassistant/components/portainer/coordinator.py +++ b/homeassistant/components/portainer/coordinator.py @@ -21,6 +21,7 @@ ) from pyportainer.models.docker_inspect import DockerInfo, DockerVersion from pyportainer.models.portainer import Endpoint +from pyportainer.models.stacks import Stack from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL @@ -28,7 +29,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONTAINER_STATE_RUNNING, DOMAIN, ENDPOINT_STATUS_DOWN +from .const import DOMAIN, ContainerState, EndpointStatus type PortainerConfigEntry = ConfigEntry[PortainerCoordinator] @@ -48,6 +49,7 @@ class PortainerCoordinatorData: docker_version: DockerVersion docker_info: DockerInfo docker_system_df: DockerSystemDF + stacks: dict[str, PortainerStackData] @dataclass(slots=True) @@ -57,6 +59,15 @@ class PortainerContainerData: container: DockerContainer stats: DockerContainerStats | None stats_pre: DockerContainerStats | None + stack: Stack | None + + +@dataclass(slots=True) +class PortainerStackData: + """Stack data held by the Portainer coordinator.""" + + stack: Stack + container_count: int = 0 class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorData]]): @@ -82,6 +93,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.new_endpoints_callbacks: list[ Callable[[list[PortainerCoordinatorData]], None] @@ -91,6 +103,9 @@ def __init__( [list[tuple[PortainerCoordinatorData, PortainerContainerData]]], None ] ] = [] + self.new_stacks_callbacks: list[ + Callable[[list[tuple[PortainerCoordinatorData, PortainerStackData]]], None] + ] = [] async def _async_setup(self) -> None: """Set up the Portainer Data Update Coordinator.""" @@ -139,7 +154,7 @@ async def _async_update_data(self) -> dict[int, PortainerCoordinatorData]: mapped_endpoints: dict[int, PortainerCoordinatorData] = {} for endpoint in endpoints: - if endpoint.status == ENDPOINT_STATUS_DOWN: + if endpoint.status == EndpointStatus.DOWN: _LOGGER.debug( "Skipping offline endpoint: %s (ID: %d)", endpoint.name, @@ -153,35 +168,55 @@ async def _async_update_data(self) -> dict[int, PortainerCoordinatorData]: docker_version, docker_info, docker_system_df, + stacks, ) = await asyncio.gather( self.portainer.get_containers(endpoint.id), self.portainer.docker_version(endpoint.id), self.portainer.docker_info(endpoint.id), self.portainer.docker_system_df(endpoint.id), + self.portainer.get_stacks(endpoint.id), ) prev_endpoint = self.data.get(endpoint.id) if self.data else None container_map: dict[str, PortainerContainerData] = {} + stack_map: dict[str, PortainerStackData] = { + stack.name: PortainerStackData(stack=stack, container_count=0) + for stack in stacks + } # Map containers, started and stopped for container in containers: container_name = self._get_container_name(container.names[0]) prev_container = ( - prev_endpoint.containers[container_name] + prev_endpoint.containers.get(container_name) if prev_endpoint else None ) + + # Check if container belongs to a stack via docker compose label + stack_name: str | None = ( + container.labels.get("com.docker.compose.project") + or container.labels.get("com.docker.stack.namespace") + if container.labels + else None + ) + if stack_name and (stack_data := stack_map.get(stack_name)): + stack_data.container_count += 1 + container_map[container_name] = PortainerContainerData( container=container, stats=None, stats_pre=prev_container.stats if prev_container else None, + stack=stack_map[stack_name].stack + if stack_name and stack_name in stack_map + else None, ) # Separately fetch stats for running containers running_containers = [ container for container in containers - if container.state == CONTAINER_STATE_RUNNING + if container.state == ContainerState.RUNNING ] if running_containers: container_stats = dict( @@ -229,6 +264,7 @@ async def _async_update_data(self) -> dict[int, PortainerCoordinatorData]: docker_version=docker_version, docker_info=docker_info, docker_system_df=docker_system_df, + stacks=stack_map, ) self._async_add_remove_endpoints(mapped_endpoints) @@ -256,6 +292,17 @@ def _async_add_remove_endpoints( _LOGGER.debug("New containers found: %s", new_containers) self.known_containers.update(new_containers) + # Stack management + current_stacks = { + (endpoint.id, stack_name) + for endpoint in mapped_endpoints.values() + for stack_name in endpoint.stacks + } + new_stacks = current_stacks - self.known_stacks + if new_stacks: + _LOGGER.debug("New stacks found: %s", new_stacks) + self.known_stacks.update(new_stacks) + def _get_container_name(self, container_name: str) -> str: """Sanitize to get a proper container name.""" return container_name.replace("/", " ").strip() diff --git a/homeassistant/components/portainer/diagnostics.py b/homeassistant/components/portainer/diagnostics.py index 8899a93f3d238a..de53dc8033fe27 100644 --- a/homeassistant/components/portainer/diagnostics.py +++ b/homeassistant/components/portainer/diagnostics.py @@ -5,13 +5,13 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.const import CONF_API_TOKEN +from homeassistant.const import CONF_API_TOKEN, CONF_URL from homeassistant.core import HomeAssistant from . import PortainerConfigEntry from .coordinator import PortainerCoordinator -TO_REDACT = [CONF_API_TOKEN] +TO_REDACT = [CONF_API_TOKEN, CONF_URL] def _serialize_coordinator(coordinator: PortainerCoordinator) -> dict[str, Any]: diff --git a/homeassistant/components/portainer/entity.py b/homeassistant/components/portainer/entity.py index 139f74bf48cf87..9fb87248e633dc 100644 --- a/homeassistant/components/portainer/entity.py +++ b/homeassistant/components/portainer/entity.py @@ -4,6 +4,7 @@ from homeassistant.const import CONF_URL from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_NAME, DOMAIN @@ -11,6 +12,7 @@ PortainerContainerData, PortainerCoordinator, PortainerCoordinatorData, + PortainerStackData, ) @@ -25,11 +27,13 @@ class PortainerEndpointEntity(PortainerCoordinatorEntity): def __init__( self, - device_info: PortainerCoordinatorData, coordinator: PortainerCoordinator, + entity_description: EntityDescription, + device_info: PortainerCoordinatorData, ) -> None: """Initialize a Portainer endpoint.""" super().__init__(coordinator) + self.entity_description = entity_description self._device_info = device_info self.device_id = device_info.endpoint.id self._attr_device_info = DeviceInfo( @@ -44,6 +48,7 @@ def __init__( name=device_info.endpoint.name, entry_type=DeviceEntryType.SERVICE, ) + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_info.id}_{entity_description.key}" @property def available(self) -> bool: @@ -56,12 +61,14 @@ class PortainerContainerEntity(PortainerCoordinatorEntity): def __init__( self, - device_info: PortainerContainerData, coordinator: PortainerCoordinator, + entity_description: EntityDescription, + device_info: PortainerContainerData, via_device: PortainerCoordinatorData, ) -> None: """Initialize a Portainer container.""" super().__init__(coordinator) + self.entity_description = entity_description self._device_info = device_info self.device_id = self._device_info.container.id self.endpoint_id = via_device.endpoint.id @@ -86,13 +93,18 @@ def __init__( ), model="Container", name=self.device_name, + # If the container belongs to a stack, nest it under the stack + # else it's the endpoint via_device=( DOMAIN, - f"{self.coordinator.config_entry.entry_id}_{self.endpoint_id}", + f"{coordinator.config_entry.entry_id}_{self.endpoint_id}_stack_{device_info.stack.id}" + if device_info.stack + else f"{coordinator.config_entry.entry_id}_{self.endpoint_id}", ), translation_key=None if self.device_name else "unknown_container", entry_type=DeviceEntryType.SERVICE, ) + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}" @property def available(self) -> bool: @@ -107,3 +119,57 @@ def available(self) -> bool: def container_data(self) -> PortainerContainerData: """Return the coordinator data for this container.""" return self.coordinator.data[self.endpoint_id].containers[self.device_name] + + +class PortainerStackEntity(PortainerCoordinatorEntity): + """Base implementation for Portainer stack.""" + + def __init__( + self, + coordinator: PortainerCoordinator, + entity_description: EntityDescription, + device_info: PortainerStackData, + via_device: PortainerCoordinatorData, + ) -> None: + """Initialize a Portainer stack.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._device_info = device_info + self.stack_id = device_info.stack.id + self.device_name = device_info.stack.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}_stack_{self.stack_id}", + ) + }, + manufacturer=DEFAULT_NAME, + configuration_url=URL( + f"{coordinator.config_entry.data[CONF_URL]}#!/{self.endpoint_id}/docker/stacks/{self.device_name}" + ), + model="Stack", + name=self.device_name, + via_device=( + DOMAIN, + f"{coordinator.config_entry.entry_id}_{self.endpoint_id}", + ), + ) + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.stack_id}_{entity_description.key}" + + @property + def available(self) -> bool: + """Return if the stack is available.""" + return ( + super().available + and self.endpoint_id in self.coordinator.data + and self.device_name in self.coordinator.data[self.endpoint_id].stacks + ) + + @property + def stack_data(self) -> PortainerStackData: + """Return the coordinator data for this stack.""" + return self.coordinator.data[self.endpoint_id].stacks[self.device_name] diff --git a/homeassistant/components/portainer/icons.json b/homeassistant/components/portainer/icons.json index 3a9331967dfb6e..319efef85dc061 100644 --- a/homeassistant/components/portainer/icons.json +++ b/homeassistant/components/portainer/icons.json @@ -1,5 +1,13 @@ { "entity": { + "button": { + "pause_container": { + "default": "mdi:pause-circle" + }, + "resume_container": { + "default": "mdi:play" + } + }, "sensor": { "api_version": { "default": "mdi:api" @@ -70,6 +78,12 @@ "operating_system_version": { "default": "mdi:alpha-v-box" }, + "stack_containers_count": { + "default": "mdi:server" + }, + "stack_type": { + "default": "mdi:server" + }, "volume_disk_usage_total_size": { "default": "mdi:harddisk" } @@ -80,6 +94,12 @@ "state": { "on": "mdi:arrow-up-box" } + }, + "stack": { + "default": "mdi:arrow-down-box", + "state": { + "on": "mdi:arrow-up-box" + } } } }, diff --git a/homeassistant/components/portainer/manifest.json b/homeassistant/components/portainer/manifest.json index 1dcb4a0e6f1e1e..ecbbd05e4dcfa7 100644 --- a/homeassistant/components/portainer/manifest.json +++ b/homeassistant/components/portainer/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/portainer", "integration_type": "service", "iot_class": "local_polling", - "quality_scale": "bronze", - "requirements": ["pyportainer==1.0.23"] + "quality_scale": "platinum", + "requirements": ["pyportainer==1.0.33"] } diff --git a/homeassistant/components/portainer/quality_scale.yaml b/homeassistant/components/portainer/quality_scale.yaml index f058560cceb825..cb4731e114844c 100644 --- a/homeassistant/components/portainer/quality_scale.yaml +++ b/homeassistant/components/portainer/quality_scale.yaml @@ -30,11 +30,8 @@ rules: entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: todo - reauthentication-flow: - status: todo - comment: | - No reauthentication flow is defined. It will be done in a next iteration. + parallel-updates: done + reauthentication-flow: done test-coverage: done # Gold devices: done @@ -47,25 +44,27 @@ rules: status: exempt comment: | No discovery is implemented, since it's software based. - 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 - exception-translations: todo - icon-translations: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: 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: todo - stale-devices: todo - + repair-issues: + status: exempt + comment: | + No repair issues are implemented, currently. + stale-devices: done # Platinum - async-dependency: todo + async-dependency: done inject-websession: done strict-typing: done diff --git a/homeassistant/components/portainer/sensor.py b/homeassistant/components/portainer/sensor.py index 395b6e9d60eed5..503c6e1093ec56 100644 --- a/homeassistant/components/portainer/sensor.py +++ b/homeassistant/components/portainer/sensor.py @@ -17,18 +17,20 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import StackType from .coordinator import ( PortainerConfigEntry, PortainerContainerData, - PortainerCoordinator, + PortainerStackData, ) from .entity import ( PortainerContainerEntity, PortainerCoordinatorData, PortainerEndpointEntity, + PortainerStackEntity, ) -PARALLEL_UPDATES = 1 +PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) @@ -45,6 +47,13 @@ class PortainerEndpointSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[PortainerCoordinatorData], StateType] +@dataclass(frozen=True, kw_only=True) +class PortainerStackSensorEntityDescription(SensorEntityDescription): + """Class to hold Portainer stack sensor description.""" + + value_fn: Callable[[PortainerStackData], StateType] + + CONTAINER_SENSORS: tuple[PortainerContainerSensorEntityDescription, ...] = ( PortainerContainerSensorEntityDescription( key="image", @@ -278,6 +287,32 @@ class PortainerEndpointSensorEntityDescription(SensorEntityDescription): ), ) +STACK_SENSORS: tuple[PortainerStackSensorEntityDescription, ...] = ( + PortainerStackSensorEntityDescription( + key="stack_type", + translation_key="stack_type", + value_fn=lambda data: ( + "swarm" + if data.stack.type == StackType.SWARM + else "compose" + if data.stack.type == StackType.COMPOSE + else "kubernetes" + if data.stack.type == StackType.KUBERNETES + else None + ), + device_class=SensorDeviceClass.ENUM, + options=["swarm", "compose", "kubernetes"], + entity_category=EntityCategory.DIAGNOSTIC, + ), + PortainerStackSensorEntityDescription( + key="stack_containers_count", + translation_key="stack_containers_count", + value_fn=lambda data: data.container_count, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -315,8 +350,24 @@ def _async_add_new_containers( for entity_description in CONTAINER_SENSORS ) + def _async_add_new_stacks( + stacks: list[tuple[PortainerCoordinatorData, PortainerStackData]], + ) -> None: + """Add new stack sensors.""" + async_add_entities( + PortainerStackSensor( + coordinator, + entity_description, + stack, + endpoint, + ) + for (endpoint, stack) in stacks + for entity_description in STACK_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) _async_add_new_endpoints( [ @@ -332,6 +383,13 @@ def _async_add_new_containers( for container in endpoint.containers.values() ] ) + _async_add_new_stacks( + [ + (endpoint, stack) + for endpoint in coordinator.data.values() + for stack in endpoint.stacks.values() + ] + ) class PortainerContainerSensor(PortainerContainerEntity, SensorEntity): @@ -339,19 +397,6 @@ class PortainerContainerSensor(PortainerContainerEntity, SensorEntity): entity_description: PortainerContainerSensorEntityDescription - def __init__( - self, - coordinator: PortainerCoordinator, - entity_description: PortainerContainerSensorEntityDescription, - device_info: PortainerContainerData, - via_device: PortainerCoordinatorData, - ) -> None: - """Initialize the Portainer container sensor.""" - self.entity_description = entity_description - super().__init__(device_info, coordinator, via_device) - - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}" - @property def native_value(self) -> StateType: """Return the state of the sensor.""" @@ -363,20 +408,19 @@ class PortainerEndpointSensor(PortainerEndpointEntity, SensorEntity): entity_description: PortainerEndpointSensorEntityDescription - def __init__( - self, - coordinator: PortainerCoordinator, - entity_description: PortainerEndpointSensorEntityDescription, - device_info: PortainerCoordinatorData, - ) -> None: - """Initialize the Portainer endpoint sensor.""" - self.entity_description = entity_description - super().__init__(device_info, coordinator) - - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_info.id}_{entity_description.key}" - @property def native_value(self) -> StateType: """Return the state of the sensor.""" endpoint_data = self.coordinator.data[self._device_info.endpoint.id] return self.entity_description.value_fn(endpoint_data) + + +class PortainerStackSensor(PortainerStackEntity, SensorEntity): + """Representation of a Portainer stack sensor.""" + + entity_description: PortainerStackSensorEntityDescription + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.stack_data) diff --git a/homeassistant/components/portainer/strings.json b/homeassistant/components/portainer/strings.json index d7e53bcdc0965c..e50d48849dbe93 100644 --- a/homeassistant/components/portainer/strings.json +++ b/homeassistant/components/portainer/strings.json @@ -3,7 +3,8 @@ "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%]" + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "The Portainer instance ID does not match the previously configured instance. This can occur if the device was reset or reconfigured outside of Home Assistant." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -65,8 +66,14 @@ "images_prune": { "name": "Prune unused images" }, + "pause_container": { + "name": "Pause container" + }, "restart_container": { "name": "Restart container" + }, + "resume_container": { + "name": "Resume container" } }, "sensor": { @@ -147,6 +154,18 @@ "operating_system_version": { "name": "Operating system version" }, + "stack_containers_count": { + "name": "Containers", + "unit_of_measurement": "containers" + }, + "stack_type": { + "name": "Type", + "state": { + "compose": "Compose", + "kubernetes": "Kubernetes", + "swarm": "Swarm" + } + }, "volume_disk_usage_total_size": { "name": "Volume disk usage total size" } @@ -154,6 +173,9 @@ "switch": { "container": { "name": "Container" + }, + "stack": { + "name": "Stack" } } }, diff --git a/homeassistant/components/portainer/switch.py b/homeassistant/components/portainer/switch.py index 8a45fb8eb702b4..478c991f513a26 100644 --- a/homeassistant/components/portainer/switch.py +++ b/homeassistant/components/portainer/switch.py @@ -23,9 +23,17 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import PortainerConfigEntry -from .const import DOMAIN -from .coordinator import PortainerContainerData, PortainerCoordinator -from .entity import PortainerContainerEntity, PortainerCoordinatorData +from .const import DOMAIN, StackStatus +from .coordinator import ( + PortainerContainerData, + PortainerCoordinator, + PortainerStackData, +) +from .entity import ( + PortainerContainerEntity, + PortainerCoordinatorData, + PortainerStackEntity, +) @dataclass(frozen=True, kw_only=True) @@ -33,51 +41,67 @@ class PortainerSwitchEntityDescription(SwitchEntityDescription): """Class to hold Portainer switch description.""" is_on_fn: Callable[[PortainerContainerData], bool | None] - turn_on_fn: Callable[[str, Portainer, int, str], Coroutine[Any, Any, None]] - turn_off_fn: Callable[[str, Portainer, int, str], Coroutine[Any, Any, None]] + turn_on_fn: Callable[[Portainer], Callable[[int, str], Coroutine[Any, Any, None]]] + turn_off_fn: Callable[[Portainer], Callable[[int, str], Coroutine[Any, Any, None]]] + + +@dataclass(frozen=True, kw_only=True) +class PortainerStackSwitchEntityDescription(SwitchEntityDescription): + """Class to hold Portainer stack switch description.""" + + is_on_fn: Callable[[PortainerStackData], bool | None] + turn_on_fn: Callable[[Portainer], Callable[..., Coroutine[Any, Any, Any]]] + turn_off_fn: Callable[[Portainer], Callable[..., Coroutine[Any, Any, Any]]] PARALLEL_UPDATES = 1 -async def perform_action( - action: str, portainer: Portainer, endpoint_id: int, container_id: str +async def _perform_action( + coordinator: PortainerCoordinator, + coroutine: Coroutine[Any, Any, Any], ) -> None: - """Perform an action on a container.""" + """Perform a Portainer action with error handling and coordinator refresh.""" try: - match action: - case "start": - await portainer.start_container(endpoint_id, container_id) - case "stop": - await portainer.stop_container(endpoint_id, container_id) + await coroutine except PortainerAuthenticationError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="invalid_auth", - translation_placeholders={"error": repr(err)}, + translation_key="invalid_auth_no_details", ) from err except PortainerConnectionError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="cannot_connect", - translation_placeholders={"error": repr(err)}, + translation_key="cannot_connect_no_details", ) from err except PortainerTimeoutError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="timeout_connect", - translation_placeholders={"error": repr(err)}, + translation_key="timeout_connect_no_details", ) from err + else: + await coordinator.async_request_refresh() -SWITCHES: tuple[PortainerSwitchEntityDescription, ...] = ( +CONTAINER_SWITCHES: tuple[PortainerSwitchEntityDescription, ...] = ( PortainerSwitchEntityDescription( key="container", translation_key="container", device_class=SwitchDeviceClass.SWITCH, is_on_fn=lambda data: data.container.state == "running", - turn_on_fn=perform_action, - turn_off_fn=perform_action, + turn_on_fn=lambda portainer: portainer.start_container, + turn_off_fn=lambda portainer: portainer.stop_container, + ), +) + +STACK_SWITCHES: tuple[PortainerStackSwitchEntityDescription, ...] = ( + PortainerStackSwitchEntityDescription( + key="stack", + translation_key="stack", + device_class=SwitchDeviceClass.SWITCH, + is_on_fn=lambda data: data.stack.status == StackStatus.ACTIVE, + turn_on_fn=lambda portainer: portainer.start_stack, + turn_off_fn=lambda portainer: portainer.stop_stack, ), ) @@ -102,10 +126,26 @@ def _async_add_new_containers( endpoint, ) for (endpoint, container) in containers - for entity_description in SWITCHES + for entity_description in CONTAINER_SWITCHES + ) + + def _async_add_new_stacks( + stacks: list[tuple[PortainerCoordinatorData, PortainerStackData]], + ) -> None: + """Add new stack switch sensors.""" + async_add_entities( + PortainerStackSwitch( + coordinator, + entity_description, + stack, + endpoint, + ) + for (endpoint, stack) in stacks + for entity_description in STACK_SWITCHES ) coordinator.new_containers_callbacks.append(_async_add_new_containers) + coordinator.new_stacks_callbacks.append(_async_add_new_stacks) _async_add_new_containers( [ (endpoint, container) @@ -113,6 +153,13 @@ def _async_add_new_containers( for container in endpoint.containers.values() ] ) + _async_add_new_stacks( + [ + (endpoint, stack) + for endpoint in coordinator.data.values() + for stack in endpoint.stacks.values() + ] + ) class PortainerContainerSwitch(PortainerContainerEntity, SwitchEntity): @@ -120,19 +167,6 @@ class PortainerContainerSwitch(PortainerContainerEntity, SwitchEntity): entity_description: PortainerSwitchEntityDescription - def __init__( - self, - coordinator: PortainerCoordinator, - entity_description: PortainerSwitchEntityDescription, - device_info: PortainerContainerData, - via_device: PortainerCoordinatorData, - ) -> None: - """Initialize the Portainer container switch.""" - self.entity_description = entity_description - super().__init__(device_info, coordinator, via_device) - - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}" - @property def is_on(self) -> bool | None: """Return the state of the device.""" @@ -140,20 +174,47 @@ def is_on(self) -> bool | None: async def async_turn_on(self, **kwargs: Any) -> None: """Start (turn on) the container.""" - await self.entity_description.turn_on_fn( - "start", - self.coordinator.portainer, - self.endpoint_id, - self.container_data.container.id, + await _perform_action( + self.coordinator, + self.entity_description.turn_on_fn(self.coordinator.portainer)( + self.endpoint_id, self.container_data.container.id + ), ) - await self.coordinator.async_request_refresh() async def async_turn_off(self, **kwargs: Any) -> None: """Stop (turn off) the container.""" - await self.entity_description.turn_off_fn( - "stop", - self.coordinator.portainer, - self.endpoint_id, - self.container_data.container.id, + await _perform_action( + self.coordinator, + self.entity_description.turn_off_fn(self.coordinator.portainer)( + self.endpoint_id, self.container_data.container.id + ), + ) + + +class PortainerStackSwitch(PortainerStackEntity, SwitchEntity): + """Representation of a Portainer stack switch.""" + + entity_description: PortainerStackSwitchEntityDescription + + @property + def is_on(self) -> bool | None: + """Return the state of the device.""" + return self.entity_description.is_on_fn(self.stack_data) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Start (turn on) the stack.""" + await _perform_action( + self.coordinator, + self.entity_description.turn_on_fn(self.coordinator.portainer)( + self.endpoint_id, self.stack_data.stack.id + ), + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Stop (turn off) the stack.""" + await _perform_action( + self.coordinator, + self.entity_description.turn_off_fn(self.coordinator.portainer)( + self.endpoint_id, self.stack_data.stack.id + ), ) - await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/powerfox/__init__.py b/homeassistant/components/powerfox/__init__.py index 06ede9dc2c2749..161b8c55e6544d 100644 --- a/homeassistant/components/powerfox/__init__.py +++ b/homeassistant/components/powerfox/__init__.py @@ -4,13 +4,19 @@ import asyncio -from powerfox import DeviceType, Powerfox, PowerfoxConnectionError +from powerfox import ( + DeviceType, + Powerfox, + PowerfoxAuthenticationError, + PowerfoxConnectionError, +) from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from .const import DOMAIN from .coordinator import ( PowerfoxConfigEntry, PowerfoxDataUpdateCoordinator, @@ -30,9 +36,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: PowerfoxConfigEntry) -> try: devices = await client.all_devices() + except PowerfoxAuthenticationError as err: + await client.close() + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed", + ) from err except PowerfoxConnectionError as err: await client.close() - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from err coordinators: list[ PowerfoxDataUpdateCoordinator | PowerfoxReportDataUpdateCoordinator diff --git a/homeassistant/components/powerfox/coordinator.py b/homeassistant/components/powerfox/coordinator.py index 0f00d94bdf031b..ae0de87d3eefb8 100644 --- a/homeassistant/components/powerfox/coordinator.py +++ b/homeassistant/components/powerfox/coordinator.py @@ -11,6 +11,7 @@ PowerfoxAuthenticationError, PowerfoxConnectionError, PowerfoxNoDataError, + PowerfoxPrivacyError, Poweropti, ) @@ -56,9 +57,27 @@ async def _async_update_data(self) -> T: try: return await self._async_fetch_data() except PowerfoxAuthenticationError as err: - raise ConfigEntryAuthFailed(err) from err - except (PowerfoxConnectionError, PowerfoxNoDataError) as err: - raise UpdateFailed(err) from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed", + ) from err + except PowerfoxConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from err + except PowerfoxNoDataError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="no_data_error", + translation_placeholders={"device_name": self.device.name}, + ) from err + except PowerfoxPrivacyError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="privacy_error", + translation_placeholders={"device_name": self.device.name}, + ) from err async def _async_fetch_data(self) -> T: """Fetch data from the Powerfox API.""" diff --git a/homeassistant/components/powerfox/manifest.json b/homeassistant/components/powerfox/manifest.json index a553d463efe18f..6a7bf4f2f0a2f1 100644 --- a/homeassistant/components/powerfox/manifest.json +++ b/homeassistant/components/powerfox/manifest.json @@ -1,13 +1,13 @@ { "domain": "powerfox", - "name": "Powerfox", + "name": "Powerfox Cloud", "codeowners": ["@klaasnicolaas"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/powerfox", "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["powerfox==2.0.0"], + "requirements": ["powerfox==2.1.1"], "zeroconf": [ { "name": "powerfox*", diff --git a/homeassistant/components/powerfox/strings.json b/homeassistant/components/powerfox/strings.json index 4d98efa8d1590f..6b98677cf19260 100644 --- a/homeassistant/components/powerfox/strings.json +++ b/homeassistant/components/powerfox/strings.json @@ -114,5 +114,19 @@ "name": "Warm water" } } + }, + "exceptions": { + "auth_failed": { + "message": "Authentication with the Powerfox service failed. Please re-authenticate your account." + }, + "connection_error": { + "message": "Could not connect to the Powerfox service. Please check your network connection." + }, + "no_data_error": { + "message": "No data available for device \"{device_name}\". The device may not have reported data yet." + }, + "privacy_error": { + "message": "Data for device \"{device_name}\" is restricted due to privacy settings in the Powerfox app." + } } } diff --git a/homeassistant/components/powerfox_local/__init__.py b/homeassistant/components/powerfox_local/__init__.py new file mode 100644 index 00000000000000..89398607fa710e --- /dev/null +++ b/homeassistant/components/powerfox_local/__init__.py @@ -0,0 +1,30 @@ +"""The Powerfox Local integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import PowerfoxLocalConfigEntry, PowerfoxLocalDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry( + hass: HomeAssistant, entry: PowerfoxLocalConfigEntry +) -> bool: + """Set up Powerfox Local from a config entry.""" + coordinator = PowerfoxLocalDataUpdateCoordinator(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: PowerfoxLocalConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/powerfox_local/config_flow.py b/homeassistant/components/powerfox_local/config_flow.py new file mode 100644 index 00000000000000..61850cf28e5b3d --- /dev/null +++ b/homeassistant/components/powerfox_local/config_flow.py @@ -0,0 +1,175 @@ +"""Config flow for Powerfox Local integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from powerfox import PowerfoxAuthenticationError, PowerfoxConnectionError, PowerfoxLocal +import voluptuous as vol + +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + SOURCE_USER, + ConfigFlow, + ConfigFlowResult, +) +from homeassistant.const import CONF_API_KEY, CONF_HOST +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from .const import DOMAIN + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_API_KEY): str, + } +) + +STEP_REAUTH_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } +) + + +class PowerfoxLocalConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Powerfox Local.""" + + _host: str + _api_key: str + _device_id: str + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step.""" + errors = {} + + if user_input is not None: + self._host = user_input[CONF_HOST] + self._api_key = user_input[CONF_API_KEY] + self._device_id = self._api_key + + try: + await self._async_validate_connection() + except PowerfoxAuthenticationError: + errors["base"] = "invalid_auth" + except PowerfoxConnectionError: + errors["base"] = "cannot_connect" + else: + if self.source == SOURCE_USER: + return self._async_create_entry() + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data={ + CONF_HOST: self._host, + CONF_API_KEY: self._api_key, + }, + ) + + 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.""" + self._host = discovery_info.host + self._device_id = discovery_info.properties["id"] + self._api_key = self._device_id + + try: + await self._async_validate_connection() + except PowerfoxAuthenticationError, PowerfoxConnectionError: + return self.async_abort(reason="cannot_connect") + + self.context["title_placeholders"] = { + "name": f"Poweropti ({self._device_id[-5:]})" + } + + self._set_confirm_only() + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={"host": self._host}, + ) + + async def async_step_zeroconf_confirm( + self, _: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a confirmation flow for zeroconf discovery.""" + return self._async_create_entry() + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication flow.""" + self._host = entry_data[CONF_HOST] + 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 = {} + + if user_input is not None: + self._api_key = user_input[CONF_API_KEY] + reauth_entry = self._get_reauth_entry() + client = PowerfoxLocal( + host=reauth_entry.data[CONF_HOST], + api_key=user_input[CONF_API_KEY], + session=async_get_clientsession(self.hass), + ) + try: + await client.value() + except PowerfoxAuthenticationError: + errors["base"] = "invalid_auth" + except PowerfoxConnectionError: + errors["base"] = "cannot_connect" + else: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates=user_input, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_REAUTH_DATA_SCHEMA, + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + return await self.async_step_user() + + def _async_create_entry(self) -> ConfigFlowResult: + """Create a config entry.""" + return self.async_create_entry( + title=f"Poweropti ({self._device_id[-5:]})", + data={ + CONF_HOST: self._host, + CONF_API_KEY: self._api_key, + }, + ) + + async def _async_validate_connection(self) -> None: + """Validate the connection and set unique ID.""" + client = PowerfoxLocal( + host=self._host, + api_key=self._api_key, + session=async_get_clientsession(self.hass), + ) + await client.value() + + await self.async_set_unique_id(self._device_id, raise_on_progress=False) + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch() + else: + self._abort_if_unique_id_configured(updates={CONF_HOST: self._host}) diff --git a/homeassistant/components/powerfox_local/const.py b/homeassistant/components/powerfox_local/const.py new file mode 100644 index 00000000000000..f600db578aea7f --- /dev/null +++ b/homeassistant/components/powerfox_local/const.py @@ -0,0 +1,11 @@ +"""Constants for the Powerfox Local integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Final + +DOMAIN: Final = "powerfox_local" +LOGGER = logging.getLogger(__package__) +SCAN_INTERVAL = timedelta(seconds=5) diff --git a/homeassistant/components/powerfox_local/coordinator.py b/homeassistant/components/powerfox_local/coordinator.py new file mode 100644 index 00000000000000..b8a2bfe8a23a0a --- /dev/null +++ b/homeassistant/components/powerfox_local/coordinator.py @@ -0,0 +1,60 @@ +"""Coordinator for Powerfox Local integration.""" + +from __future__ import annotations + +from powerfox import ( + LocalResponse, + PowerfoxAuthenticationError, + PowerfoxConnectionError, + PowerfoxLocal, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_HOST +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 PowerfoxLocalConfigEntry = ConfigEntry[PowerfoxLocalDataUpdateCoordinator] + + +class PowerfoxLocalDataUpdateCoordinator(DataUpdateCoordinator[LocalResponse]): + """Class to manage fetching Powerfox local data.""" + + config_entry: PowerfoxLocalConfigEntry + + def __init__(self, hass: HomeAssistant, entry: PowerfoxLocalConfigEntry) -> None: + """Initialize the coordinator.""" + self.client = PowerfoxLocal( + host=entry.data[CONF_HOST], + api_key=entry.data[CONF_API_KEY], + session=async_get_clientsession(hass), + ) + self.device_id: str = entry.data[CONF_API_KEY] + super().__init__( + hass, + LOGGER, + config_entry=entry, + name=f"{DOMAIN}_{entry.data[CONF_HOST]}", + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self) -> LocalResponse: + """Fetch data from the local poweropti.""" + try: + return await self.client.value() + except PowerfoxAuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed", + translation_placeholders={"host": self.config_entry.data[CONF_HOST]}, + ) from err + except PowerfoxConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="connection_error", + translation_placeholders={"host": self.config_entry.data[CONF_HOST]}, + ) from err diff --git a/homeassistant/components/powerfox_local/diagnostics.py b/homeassistant/components/powerfox_local/diagnostics.py new file mode 100644 index 00000000000000..7cfd196cf5a256 --- /dev/null +++ b/homeassistant/components/powerfox_local/diagnostics.py @@ -0,0 +1,24 @@ +"""Support for Powerfox Local diagnostics.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant + +from .coordinator import PowerfoxLocalConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: PowerfoxLocalConfigEntry +) -> dict[str, Any]: + """Return diagnostics for Powerfox Local config entry.""" + coordinator = entry.runtime_data + + return { + "power": coordinator.data.power, + "energy_usage": coordinator.data.energy_usage, + "energy_usage_high_tariff": coordinator.data.energy_usage_high_tariff, + "energy_usage_low_tariff": coordinator.data.energy_usage_low_tariff, + "energy_return": coordinator.data.energy_return, + } diff --git a/homeassistant/components/powerfox_local/entity.py b/homeassistant/components/powerfox_local/entity.py new file mode 100644 index 00000000000000..afa49a6c16c159 --- /dev/null +++ b/homeassistant/components/powerfox_local/entity.py @@ -0,0 +1,28 @@ +"""Base entity for Powerfox Local.""" + +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 PowerfoxLocalDataUpdateCoordinator + + +class PowerfoxLocalEntity(CoordinatorEntity[PowerfoxLocalDataUpdateCoordinator]): + """Base entity for Powerfox Local.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: PowerfoxLocalDataUpdateCoordinator, + ) -> None: + """Initialize Powerfox Local entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.device_id)}, + manufacturer="Powerfox", + model="Poweropti", + serial_number=coordinator.device_id, + ) diff --git a/homeassistant/components/powerfox_local/manifest.json b/homeassistant/components/powerfox_local/manifest.json new file mode 100644 index 00000000000000..2eec1ef00b8b48 --- /dev/null +++ b/homeassistant/components/powerfox_local/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "powerfox_local", + "name": "Powerfox Local", + "codeowners": ["@klaasnicolaas"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/powerfox_local", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "platinum", + "requirements": ["powerfox==2.1.1"], + "zeroconf": [ + { + "name": "powerfox*", + "type": "_http._tcp.local." + } + ] +} diff --git a/homeassistant/components/powerfox_local/quality_scale.yaml b/homeassistant/components/powerfox_local/quality_scale.yaml new file mode 100644 index 00000000000000..2552c5a857d495 --- /dev/null +++ b/homeassistant/components/powerfox_local/quality_scale.yaml @@ -0,0 +1,90 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not provide additional actions. + 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-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: | + Each config entry represents a single device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: | + There are no entities that should be disabled by default. + entity-translations: done + exception-translations: done + icon-translations: + status: exempt + comment: | + There is no need for icon translations. + reconfiguration-flow: done + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + stale-devices: + status: exempt + comment: | + Each config entry represents a single device. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/powerfox_local/sensor.py b/homeassistant/components/powerfox_local/sensor.py new file mode 100644 index 00000000000000..10c03c05db2da7 --- /dev/null +++ b/homeassistant/components/powerfox_local/sensor.py @@ -0,0 +1,112 @@ +"""Sensors for Powerfox Local integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from powerfox import LocalResponse + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfEnergy, UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import PowerfoxLocalConfigEntry, PowerfoxLocalDataUpdateCoordinator +from .entity import PowerfoxLocalEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class PowerfoxLocalSensorEntityDescription(SensorEntityDescription): + """Describes Powerfox Local sensor entity.""" + + value_fn: Callable[[LocalResponse], float | int | None] + + +SENSORS: tuple[PowerfoxLocalSensorEntityDescription, ...] = ( + PowerfoxLocalSensorEntityDescription( + key="power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.power, + ), + PowerfoxLocalSensorEntityDescription( + key="energy_usage", + translation_key="energy_usage", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.energy_usage, + ), + PowerfoxLocalSensorEntityDescription( + key="energy_usage_high_tariff", + translation_key="energy_usage_high_tariff", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.energy_usage_high_tariff, + ), + PowerfoxLocalSensorEntityDescription( + key="energy_usage_low_tariff", + translation_key="energy_usage_low_tariff", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.energy_usage_low_tariff, + ), + PowerfoxLocalSensorEntityDescription( + key="energy_return", + translation_key="energy_return", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.energy_return, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PowerfoxLocalConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Powerfox Local sensors based on a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + PowerfoxLocalSensorEntity( + coordinator=coordinator, + description=description, + ) + for description in SENSORS + if description.value_fn(coordinator.data) is not None + ) + + +class PowerfoxLocalSensorEntity(PowerfoxLocalEntity, SensorEntity): + """Defines a Powerfox Local sensor.""" + + entity_description: PowerfoxLocalSensorEntityDescription + + def __init__( + self, + coordinator: PowerfoxLocalDataUpdateCoordinator, + description: PowerfoxLocalSensorEntityDescription, + ) -> None: + """Initialize the Powerfox Local sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.device_id}_{description.key}" + + @property + def native_value(self) -> float | int | None: + """Return the state of the entity.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/powerfox_local/strings.json b/homeassistant/components/powerfox_local/strings.json new file mode 100644 index 00000000000000..fd6ddaa07960c8 --- /dev/null +++ b/homeassistant/components/powerfox_local/strings.json @@ -0,0 +1,66 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "Please ensure you reconfigure against the same device." + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::powerfox_local::config::step::user::data_description::api_key%]" + }, + "description": "The API key for your Poweropti device is no longer valid.", + "title": "[%key:common::config_flow::title::reauth%]" + }, + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "api_key": "The API key (device ID) of your Poweropti device.", + "host": "The hostname or IP address of your Poweropti device." + }, + "description": "Set up your Poweropti device to poll locally." + }, + "zeroconf_confirm": { + "description": "Do you want to set up the Poweropti device found at {host}?", + "title": "Discovered Poweropti" + } + } + }, + "entity": { + "sensor": { + "energy_return": { + "name": "Energy return" + }, + "energy_usage": { + "name": "Energy usage" + }, + "energy_usage_high_tariff": { + "name": "Energy usage high tariff" + }, + "energy_usage_low_tariff": { + "name": "Energy usage low tariff" + } + } + }, + "exceptions": { + "auth_failed": { + "message": "Authentication with the Poweropti device at {host} failed. Please check your API key." + }, + "connection_error": { + "message": "Could not connect to the Poweropti device at {host}. Please check if the device is online and reachable." + } + } +} diff --git a/homeassistant/components/powerwall/__init__.py b/homeassistant/components/powerwall/__init__.py index d84452c044349e..f2eea199df550f 100644 --- a/homeassistant/components/powerwall/__init__.py +++ b/homeassistant/components/powerwall/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations from contextlib import AsyncExitStack -from datetime import timedelta import logging from aiohttp import CookieJar @@ -23,7 +22,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_create_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.util.network import is_ip_address from .const import ( @@ -32,13 +31,13 @@ DOMAIN, POWERWALL_API_CHANGED, POWERWALL_COORDINATOR, - UPDATE_INTERVAL, ) -from .models import ( +from .coordinator import ( PowerwallBaseInfo, PowerwallConfigEntry, PowerwallData, PowerwallRuntimeData, + PowerwallUpdateCoordinator, ) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] @@ -221,15 +220,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PowerwallConfigEntry) -> ) manager.save_auth_cookie() - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name="Powerwall site", - update_method=manager.async_update_data, - update_interval=timedelta(seconds=UPDATE_INTERVAL), - always_update=False, - ) + coordinator = PowerwallUpdateCoordinator(hass, entry, manager) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/powerwall/binary_sensor.py b/homeassistant/components/powerwall/binary_sensor.py index 100e31b1c2140b..ea5eb5b0d0309b 100644 --- a/homeassistant/components/powerwall/binary_sensor.py +++ b/homeassistant/components/powerwall/binary_sensor.py @@ -11,8 +11,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .coordinator import PowerwallConfigEntry from .entity import PowerWallEntity -from .models import PowerwallConfigEntry CONNECTED_GRID_STATUSES = { GridStatus.TRANSITION_TO_GRID, diff --git a/homeassistant/components/powerwall/models.py b/homeassistant/components/powerwall/coordinator.py similarity index 55% rename from homeassistant/components/powerwall/models.py rename to homeassistant/components/powerwall/coordinator.py index d5d79accc9ec09..80546460c151d6 100644 --- a/homeassistant/components/powerwall/models.py +++ b/homeassistant/components/powerwall/coordinator.py @@ -1,9 +1,11 @@ -"""The powerwall integration models.""" +"""Coordinator for the Tesla Powerwall integration.""" from __future__ import annotations from dataclasses import dataclass -from typing import TypedDict +from datetime import timedelta +import logging +from typing import TYPE_CHECKING, TypedDict from tesla_powerwall import ( BatteryResponse, @@ -17,8 +19,16 @@ ) from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from .const import UPDATE_INTERVAL + +if TYPE_CHECKING: + from . import PowerwallDataManager + +_LOGGER = logging.getLogger(__name__) + type PowerwallConfigEntry = ConfigEntry[PowerwallRuntimeData] @@ -51,7 +61,30 @@ class PowerwallData: class PowerwallRuntimeData(TypedDict): """Run time data for the powerwall.""" - coordinator: DataUpdateCoordinator[PowerwallData] | None + coordinator: PowerwallUpdateCoordinator | None api_instance: Powerwall base_info: PowerwallBaseInfo api_changed: bool + + +class PowerwallUpdateCoordinator(DataUpdateCoordinator[PowerwallData]): + """Coordinator for powerwall data.""" + + config_entry: PowerwallConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: PowerwallConfigEntry, + manager: PowerwallDataManager, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name="Powerwall site", + update_method=manager.async_update_data, + update_interval=timedelta(seconds=UPDATE_INTERVAL), + always_update=False, + ) diff --git a/homeassistant/components/powerwall/entity.py b/homeassistant/components/powerwall/entity.py index cad371ea42c20a..b28d75b32c8083 100644 --- a/homeassistant/components/powerwall/entity.py +++ b/homeassistant/components/powerwall/entity.py @@ -1,10 +1,9 @@ """The Tesla Powerwall integration base entity.""" +from tesla_powerwall import BatteryResponse + from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( DOMAIN, @@ -14,10 +13,10 @@ POWERWALL_BASE_INFO, POWERWALL_COORDINATOR, ) -from .models import BatteryResponse, PowerwallData, PowerwallRuntimeData +from .coordinator import PowerwallData, PowerwallRuntimeData, PowerwallUpdateCoordinator -class PowerWallEntity(CoordinatorEntity[DataUpdateCoordinator[PowerwallData]]): +class PowerWallEntity(CoordinatorEntity[PowerwallUpdateCoordinator]): """Base class for powerwall entities.""" _attr_has_entity_name = True @@ -45,7 +44,7 @@ def data(self) -> PowerwallData: return self.coordinator.data -class BatteryEntity(CoordinatorEntity[DataUpdateCoordinator[PowerwallData]]): +class BatteryEntity(CoordinatorEntity[PowerwallUpdateCoordinator]): """Base class for battery entities.""" _attr_has_entity_name = True diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index b44fea05638b71..b8df599feb6623 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -7,7 +7,7 @@ from operator import attrgetter, methodcaller from typing import TYPE_CHECKING -from tesla_powerwall import GridState, MeterResponse, MeterType +from tesla_powerwall import BatteryResponse, GridState, MeterResponse, MeterType from homeassistant.components.sensor import ( SensorDeviceClass, @@ -29,8 +29,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import POWERWALL_COORDINATOR +from .coordinator import PowerwallConfigEntry, PowerwallRuntimeData from .entity import BatteryEntity, PowerWallEntity -from .models import BatteryResponse, PowerwallConfigEntry, PowerwallRuntimeData _METER_DIRECTION_EXPORT = "export" _METER_DIRECTION_IMPORT = "import" diff --git a/homeassistant/components/powerwall/switch.py b/homeassistant/components/powerwall/switch.py index a874161de5b0b4..685faf73f967ac 100644 --- a/homeassistant/components/powerwall/switch.py +++ b/homeassistant/components/powerwall/switch.py @@ -10,8 +10,8 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .coordinator import PowerwallConfigEntry, PowerwallRuntimeData from .entity import PowerWallEntity -from .models import PowerwallConfigEntry, PowerwallRuntimeData OFF_GRID_STATUSES = { GridStatus.TRANSITION_TO_ISLAND, diff --git a/homeassistant/components/prana/__init__.py b/homeassistant/components/prana/__init__.py index 2535e124d27597..68c3a7f2f65f8e 100644 --- a/homeassistant/components/prana/__init__.py +++ b/homeassistant/components/prana/__init__.py @@ -14,13 +14,11 @@ _LOGGER = logging.getLogger(__name__) -# Keep platforms sorted alphabetically to satisfy lint rule -PLATFORMS = [Platform.SWITCH] +PLATFORMS = [Platform.FAN, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: PranaConfigEntry) -> bool: """Set up Prana from a config entry.""" - coordinator = PranaCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/prana/config_flow.py b/homeassistant/components/prana/config_flow.py index d7bfeaaf4ec97a..1bf6b8e63fc542 100644 --- a/homeassistant/components/prana/config_flow.py +++ b/homeassistant/components/prana/config_flow.py @@ -5,7 +5,7 @@ from prana_local_api_client.exceptions import PranaApiCommunicationError from prana_local_api_client.models.prana_device_info import PranaDeviceInfo -from prana_local_api_client.prana_api_client import PranaLocalApiClient +from prana_local_api_client.prana_local_api_client import PranaLocalApiClient import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult diff --git a/homeassistant/components/prana/coordinator.py b/homeassistant/components/prana/coordinator.py index c0bf64041ec3f2..c19fa01af03a7e 100644 --- a/homeassistant/components/prana/coordinator.py +++ b/homeassistant/components/prana/coordinator.py @@ -12,7 +12,7 @@ ) from prana_local_api_client.models.prana_device_info import PranaDeviceInfo from prana_local_api_client.models.prana_state import PranaState -from prana_local_api_client.prana_api_client import PranaLocalApiClient +from prana_local_api_client.prana_local_api_client import PranaLocalApiClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST diff --git a/homeassistant/components/prana/entity.py b/homeassistant/components/prana/entity.py index ea149fb28425c1..2ade7c7e4c3706 100644 --- a/homeassistant/components/prana/entity.py +++ b/homeassistant/components/prana/entity.py @@ -1,10 +1,7 @@ """Defines base Prana entity.""" -from dataclasses import dataclass -import logging from typing import TYPE_CHECKING -from homeassistant.components.switch import StrEnum from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -12,26 +9,16 @@ from .const import DOMAIN from .coordinator import PranaCoordinator -_LOGGER = logging.getLogger(__name__) - - -@dataclass(frozen=True, kw_only=True) -class PranaEntityDescription(EntityDescription): - """Description for all Prana entities.""" - - key: StrEnum - class PranaBaseEntity(CoordinatorEntity[PranaCoordinator]): """Defines a base Prana entity.""" _attr_has_entity_name = True - _attr_entity_description: PranaEntityDescription def __init__( self, coordinator: PranaCoordinator, - description: PranaEntityDescription, + description: EntityDescription, ) -> None: """Initialize the Prana entity.""" super().__init__(coordinator) diff --git a/homeassistant/components/prana/fan.py b/homeassistant/components/prana/fan.py new file mode 100644 index 00000000000000..58948720631e5f --- /dev/null +++ b/homeassistant/components/prana/fan.py @@ -0,0 +1,188 @@ +"""Fan platform for Prana integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum +import math +from typing import Any + +from prana_local_api_client.models.prana_state import FanState + +from homeassistant.components.fan import ( + FanEntity, + FanEntityDescription, + FanEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) +from homeassistant.util.scaling import int_states_in_range + +from .coordinator import PranaConfigEntry, PranaCoordinator +from .entity import PranaBaseEntity + +PARALLEL_UPDATES = 1 + +# The Prana device API expects fan speed values in scaled units (tenths of a speed step) +# rather than the raw step value used internally by this integration. This factor is +# applied when sending speeds to the API to match its expected units. +PRANA_SPEED_MULTIPLIER = 10 + + +class PranaFanType(StrEnum): + """Enumerates Prana fan types exposed by the device API.""" + + SUPPLY = "supply" + EXTRACT = "extract" + BOUNDED = "bounded" + + +@dataclass(frozen=True, kw_only=True) +class PranaFanEntityDescription(FanEntityDescription): + """Description of a Prana fan entity.""" + + key: PranaFanType + value_fn: Callable[[PranaCoordinator], FanState] + speed_range: Callable[[PranaCoordinator], tuple[int, int]] + + +ENTITIES: tuple[PranaFanEntityDescription, ...] = ( + PranaFanEntityDescription( + key=PranaFanType.SUPPLY, + translation_key="supply", + value_fn=lambda coord: ( + coord.data.supply if not coord.data.bound else coord.data.bounded + ), + speed_range=lambda coord: ( + 1, + coord.data.supply.max_speed + if not coord.data.bound + else coord.data.bounded.max_speed, + ), + ), + PranaFanEntityDescription( + key=PranaFanType.EXTRACT, + translation_key="extract", + value_fn=lambda coord: ( + coord.data.extract if not coord.data.bound else coord.data.bounded + ), + speed_range=lambda coord: ( + 1, + coord.data.extract.max_speed + if not coord.data.bound + else coord.data.bounded.max_speed, + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PranaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Prana fan entities from a config entry.""" + async_add_entities( + PranaFan(entry.runtime_data, entity_description) + for entity_description in ENTITIES + ) + + +class PranaFan(PranaBaseEntity, FanEntity): + """Representation of a Prana fan entity.""" + + entity_description: PranaFanEntityDescription + _attr_preset_modes = ["night", "boost"] + _attr_supported_features = ( + FanEntityFeature.SET_SPEED + | FanEntityFeature.TURN_ON + | FanEntityFeature.TURN_OFF + | FanEntityFeature.PRESET_MODE + ) + + @property + def _api_target_key(self) -> str: + """Return the correct target key for API commands based on bounded state.""" + # If the device is in bound mode, both supply and extract fans control the same bounded fan speeds. + if self.coordinator.data.bound: + return PranaFanType.BOUNDED + # Otherwise, return the specific fan type (supply or extract) for API commands. + return self.entity_description.key + + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return int_states_in_range( + self.entity_description.speed_range(self.coordinator) + ) + + @property + def percentage(self) -> int | None: + """Return the current fan speed percentage.""" + current_speed = self.entity_description.value_fn(self.coordinator).speed + return ranged_value_to_percentage( + self.entity_description.speed_range(self.coordinator), current_speed + ) + + async def async_set_percentage(self, percentage: int) -> None: + """Set fan speed (0-100%) by converting to device-specific speed steps.""" + if percentage == 0: + await self.async_turn_off() + return + await self.coordinator.api_client.set_speed( + math.ceil( + percentage_to_ranged_value( + self.entity_description.speed_range(self.coordinator), + percentage, + ) + ) + * PRANA_SPEED_MULTIPLIER, + self._api_target_key, + ) + await self.coordinator.async_refresh() + + @property + def is_on(self) -> bool: + """Return true if the fan is on.""" + return self.entity_description.value_fn(self.coordinator).is_on + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn the fan on and optionally set speed or preset mode.""" + if percentage == 0: + await self.async_turn_off() + return + + await self.coordinator.api_client.set_speed_is_on(True, self._api_target_key) + if percentage is not None: + await self.async_set_percentage(percentage) + if preset_mode is not None: + await self.async_set_preset_mode(preset_mode) + if percentage is None and preset_mode is None: + await self.coordinator.async_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the fan off.""" + await self.coordinator.api_client.set_speed_is_on(False, self._api_target_key) + await self.coordinator.async_refresh() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode (e.g., night or boost).""" + await self.coordinator.api_client.set_switch(preset_mode, True) + await self.coordinator.async_refresh() + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + if self.coordinator.data.night: + return "night" + if self.coordinator.data.boost: + return "boost" + return None diff --git a/homeassistant/components/prana/icons.json b/homeassistant/components/prana/icons.json index 4a44abd68c63d2..c22e36bd07cd55 100644 --- a/homeassistant/components/prana/icons.json +++ b/homeassistant/components/prana/icons.json @@ -1,5 +1,41 @@ { "entity": { + "fan": { + "extract": { + "default": "mdi:arrow-expand-right" + }, + "supply": { + "default": "mdi:arrow-expand-left" + } + }, + "number": { + "display_brightness": { + "default": "mdi:brightness-6", + "state": { + "0": "mdi:brightness-2", + "1": "mdi:brightness-4", + "2": "mdi:brightness-4", + "3": "mdi:brightness-5", + "4": "mdi:brightness-5", + "5": "mdi:brightness-7", + "6": "mdi:brightness-7" + } + } + }, + "sensor": { + "inside_temperature": { + "default": "mdi:home-thermometer" + }, + "inside_temperature_2": { + "default": "mdi:home-thermometer" + }, + "outside_temperature": { + "default": "mdi:thermometer" + }, + "outside_temperature_2": { + "default": "mdi:thermometer" + } + }, "switch": { "auto": { "default": "mdi:fan-auto" diff --git a/homeassistant/components/prana/manifest.json b/homeassistant/components/prana/manifest.json index 5d3baad22ddb1b..594a37a379cf98 100644 --- a/homeassistant/components/prana/manifest.json +++ b/homeassistant/components/prana/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["prana-api-client==0.10.0"], + "requirements": ["prana-api-client==0.12.0"], "zeroconf": [ { "type": "_prana._tcp.local." diff --git a/homeassistant/components/prana/number.py b/homeassistant/components/prana/number.py new file mode 100644 index 00000000000000..5e4a9ab026eb40 --- /dev/null +++ b/homeassistant/components/prana/number.py @@ -0,0 +1,80 @@ +"""Number platform for Prana integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +from homeassistant.components.number import ( + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import PranaConfigEntry, PranaCoordinator +from .entity import PranaBaseEntity + +PARALLEL_UPDATES = 1 + + +class PranaNumberType(StrEnum): + """Enumerates Prana number types exposed by the device API.""" + + DISPLAY_BRIGHTNESS = "display_brightness" + + +@dataclass(frozen=True, kw_only=True) +class PranaNumberEntityDescription(NumberEntityDescription): + """Description of a Prana number entity.""" + + key: PranaNumberType + value_fn: Callable[[PranaCoordinator], float | None] + set_value_fn: Callable[[Any, float], Any] + + +ENTITIES: tuple[PranaNumberEntityDescription, ...] = ( + PranaNumberEntityDescription( + key=PranaNumberType.DISPLAY_BRIGHTNESS, + translation_key="display_brightness", + native_min_value=0, + native_max_value=6, + native_step=1, + mode=NumberMode.SLIDER, + entity_category=EntityCategory.CONFIG, + value_fn=lambda coord: coord.data.brightness, + set_value_fn=lambda api, val: api.set_brightness( + 0 if val == 0 else 2 ** (int(val) - 1) + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PranaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Prana number entities from a config entry.""" + async_add_entities( + PranaNumber(entry.runtime_data, entity_description) + for entity_description in ENTITIES + ) + + +class PranaNumber(PranaBaseEntity, NumberEntity): + """Representation of a Prana number entity.""" + + entity_description: PranaNumberEntityDescription + + @property + def native_value(self) -> float | None: + """Return the entity value.""" + return self.entity_description.value_fn(self.coordinator) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + await self.entity_description.set_value_fn(self.coordinator.api_client, value) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/prana/sensor.py b/homeassistant/components/prana/sensor.py new file mode 100644 index 00000000000000..a29be3ad3da52a --- /dev/null +++ b/homeassistant/components/prana/sensor.py @@ -0,0 +1,129 @@ +"""Sensor platform for Prana integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, + StateType, +) +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + UnitOfPressure, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import PranaConfigEntry, PranaCoordinator +from .entity import PranaBaseEntity + +PARALLEL_UPDATES = 1 + + +class PranaSensorType(StrEnum): + """Enumerates Prana sensor types exposed by the device API.""" + + HUMIDITY = "humidity" + VOC = "voc" + AIR_PRESSURE = "air_pressure" + CO2 = "co2" + INSIDE_TEMPERATURE = "inside_temperature" + INSIDE_TEMPERATURE_2 = "inside_temperature_2" + OUTSIDE_TEMPERATURE = "outside_temperature" + OUTSIDE_TEMPERATURE_2 = "outside_temperature_2" + + +@dataclass(frozen=True, kw_only=True) +class PranaSensorEntityDescription(SensorEntityDescription): + """Description of a Prana sensor entity.""" + + key: PranaSensorType + state_class: SensorStateClass = SensorStateClass.MEASUREMENT + value_fn: Callable[[PranaCoordinator], StateType | None] + + +ENTITIES: tuple[PranaSensorEntityDescription, ...] = ( + PranaSensorEntityDescription( + key=PranaSensorType.HUMIDITY, + value_fn=lambda coord: coord.data.humidity, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + ), + PranaSensorEntityDescription( + key=PranaSensorType.VOC, + value_fn=lambda coord: coord.data.voc, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, + ), + PranaSensorEntityDescription( + key=PranaSensorType.AIR_PRESSURE, + value_fn=lambda coord: coord.data.air_pressure, + native_unit_of_measurement=UnitOfPressure.MMHG, + device_class=SensorDeviceClass.PRESSURE, + ), + PranaSensorEntityDescription( + key=PranaSensorType.CO2, + value_fn=lambda coord: coord.data.co2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.CO2, + ), + PranaSensorEntityDescription( + key=PranaSensorType.INSIDE_TEMPERATURE, + translation_key="inside_temperature", + value_fn=lambda coord: coord.data.inside_temperature, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + PranaSensorEntityDescription( + key=PranaSensorType.INSIDE_TEMPERATURE_2, + translation_key="inside_temperature_2", + value_fn=lambda coord: coord.data.inside_temperature_2, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + PranaSensorEntityDescription( + key=PranaSensorType.OUTSIDE_TEMPERATURE, + translation_key="outside_temperature", + value_fn=lambda coord: coord.data.outside_temperature, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + PranaSensorEntityDescription( + key=PranaSensorType.OUTSIDE_TEMPERATURE_2, + translation_key="outside_temperature_2", + value_fn=lambda coord: coord.data.outside_temperature_2, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PranaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Prana sensor entities from a config entry.""" + async_add_entities( + PranaSensor(entry.runtime_data, description) + for description in ENTITIES + if description.value_fn(entry.runtime_data) is not None + ) + + +class PranaSensor(PranaBaseEntity, SensorEntity): + """Representation of a Prana sensor entity.""" + + entity_description: PranaSensorEntityDescription + + @property + def native_value(self) -> StateType | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator) diff --git a/homeassistant/components/prana/strings.json b/homeassistant/components/prana/strings.json index fb8b40ca208602..283df344b5e6f5 100644 --- a/homeassistant/components/prana/strings.json +++ b/homeassistant/components/prana/strings.json @@ -25,6 +25,49 @@ } }, "entity": { + "fan": { + "extract": { + "name": "Extract fan", + "state_attributes": { + "preset_mode": { + "state": { + "boost": "Boost", + "night": "Night" + } + } + } + }, + "supply": { + "name": "Supply fan", + "state_attributes": { + "preset_mode": { + "state": { + "boost": "[%key:component::prana::entity::fan::extract::state_attributes::preset_mode::state::boost%]", + "night": "[%key:component::prana::entity::fan::extract::state_attributes::preset_mode::state::night%]" + } + } + } + } + }, + "number": { + "display_brightness": { + "name": "Display brightness" + } + }, + "sensor": { + "inside_temperature": { + "name": "Inside temperature" + }, + "inside_temperature_2": { + "name": "Inside temperature 2" + }, + "outside_temperature": { + "name": "Outside temperature" + }, + "outside_temperature_2": { + "name": "Outside temperature 2" + } + }, "switch": { "auto": { "name": "Auto" diff --git a/homeassistant/components/prana/switch.py b/homeassistant/components/prana/switch.py index 7c13f7d5709a9a..c6ab260bcb0899 100644 --- a/homeassistant/components/prana/switch.py +++ b/homeassistant/components/prana/switch.py @@ -1,20 +1,16 @@ """Switch platform for Prana integration.""" from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum from typing import Any -from aioesphomeapi import dataclass - -from homeassistant.components.switch import ( - StrEnum, - SwitchEntity, - SwitchEntityDescription, -) +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import PranaConfigEntry, PranaCoordinator -from .entity import PranaBaseEntity, PranaEntityDescription +from .coordinator import PranaConfigEntry, PranaCoordinator +from .entity import PranaBaseEntity PARALLEL_UPDATES = 1 @@ -32,13 +28,14 @@ class PranaSwitchType(StrEnum): @dataclass(frozen=True, kw_only=True) -class PranaSwitchEntityDescription(SwitchEntityDescription, PranaEntityDescription): +class PranaSwitchEntityDescription(SwitchEntityDescription): """Description of a Prana switch entity.""" + key: PranaSwitchType value_fn: Callable[[PranaCoordinator], bool] -ENTITIES: tuple[PranaEntityDescription, ...] = ( +ENTITIES: tuple[PranaSwitchEntityDescription, ...] = ( PranaSwitchEntityDescription( key=PranaSwitchType.BOUND, translation_key="bound", diff --git a/homeassistant/components/progettihwsw/binary_sensor.py b/homeassistant/components/progettihwsw/binary_sensor.py index 40296dcac9088f..aeec792cff1b7b 100644 --- a/homeassistant/components/progettihwsw/binary_sensor.py +++ b/homeassistant/components/progettihwsw/binary_sensor.py @@ -64,6 +64,6 @@ def __init__(self, coordinator, name, sensor: Input) -> None: self._sensor = sensor @property - def is_on(self): + def is_on(self) -> bool: """Get sensor state.""" return self.coordinator.data[self._sensor.id] diff --git a/homeassistant/components/progettihwsw/switch.py b/homeassistant/components/progettihwsw/switch.py index 256d90ae5b73be..b2f00d52439ca9 100644 --- a/homeassistant/components/progettihwsw/switch.py +++ b/homeassistant/components/progettihwsw/switch.py @@ -80,6 +80,6 @@ async def async_toggle(self, **kwargs: Any) -> None: await self.coordinator.async_request_refresh() @property - def is_on(self): + def is_on(self) -> bool: """Get switch state.""" return self.coordinator.data[self._switch.id] diff --git a/homeassistant/components/proliphix/climate.py b/homeassistant/components/proliphix/climate.py index 03f53dec390536..14b2f09018d34c 100644 --- a/homeassistant/components/proliphix/climate.py +++ b/homeassistant/components/proliphix/climate.py @@ -62,33 +62,28 @@ class ProliphixThermostat(ClimateEntity): _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT - def __init__(self, pdp): + def __init__(self, pdp: proliphix.PDP) -> None: """Initialize the thermostat.""" self._pdp = pdp - self._name = None + self._attr_name = None def update(self) -> None: """Update the data from the thermostat.""" self._pdp.update() - self._name = self._pdp.name + self._attr_name = self._pdp.name @property - def name(self): - """Return the name of the thermostat.""" - return self._name - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the device specific state attributes.""" return {ATTR_FAN: self._pdp.fan_state} @property - def current_temperature(self): + def current_temperature(self) -> float: """Return the current temperature.""" return self._pdp.cur_temp @property - def target_temperature(self): + def target_temperature(self) -> float: """Return the temperature we try to reach.""" return self._pdp.setback diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index ed9652c55c6d04..0b2f57c0444f1d 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -2,16 +2,11 @@ from __future__ import annotations -from datetime import timedelta import logging -from typing import Any -from proxmoxer import AuthenticationError, ProxmoxAPI -import requests.exceptions -from requests.exceptions import ConnectTimeout, SSLError import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -22,17 +17,13 @@ ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers import ( + config_validation as cv, + entity_registry as er, + issue_registry as ir, +) from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .common import ( - ProxmoxClient, - ResourceException, - call_api_container_vm, - parse_api_container_vm, -) from .const import ( CONF_CONTAINERS, CONF_NODE, @@ -43,17 +34,16 @@ DEFAULT_REALM, DEFAULT_VERIFY_SSL, DOMAIN, - TYPE_CONTAINER, - TYPE_VM, - UPDATE_INTERVAL, ) +from .coordinator import ProxmoxConfigEntry, ProxmoxCoordinator -PLATFORMS = [Platform.BINARY_SENSOR] - -type ProxmoxConfigEntry = ConfigEntry[ - dict[str, dict[str, dict[int, DataUpdateCoordinator[dict[str, Any] | None]]]] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.SENSOR, ] + CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.All( @@ -93,7 +83,7 @@ extra=vol.ALLOW_EXTRA, ) -LOGGER = logging.getLogger(__name__) +_LOGGER = logging.getLogger(__name__) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -150,132 +140,42 @@ async def _async_setup(hass: HomeAssistant, config: ConfigType) -> None: async def async_setup_entry(hass: HomeAssistant, entry: ProxmoxConfigEntry) -> bool: - """Set up a ProxmoxVE instance from a config entry.""" - - def build_client() -> ProxmoxClient: - """Build and return the Proxmox client connection.""" - host = entry.data[CONF_HOST] - port = entry.data[CONF_PORT] - user = entry.data[CONF_USERNAME] - realm = entry.data[CONF_REALM] - password = entry.data[CONF_PASSWORD] - verify_ssl = entry.data[CONF_VERIFY_SSL] - try: - client = ProxmoxClient(host, port, user, realm, password, verify_ssl) - client.build_client() - except AuthenticationError as ex: - raise ConfigEntryAuthFailed("Invalid credentials") from ex - except SSLError as ex: - raise ConfigEntryAuthFailed( - f"Unable to verify proxmox server SSL. Try using 'verify_ssl: false' for proxmox instance {host}:{port}" - ) from ex - except ConnectTimeout as ex: - raise ConfigEntryNotReady("Connection timed out") from ex - except requests.exceptions.ConnectionError as ex: - raise ConfigEntryNotReady(f"Host {host} is not reachable: {ex}") from ex - else: - return client - - proxmox_client = await hass.async_add_executor_job(build_client) + """Set up a ProxmoxVE from a config entry.""" + coordinator = ProxmoxCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() - coordinators: dict[ - str, dict[str, dict[int, DataUpdateCoordinator[dict[str, Any] | None]]] - ] = {} - entry.runtime_data = coordinators + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - host_name = entry.data[CONF_HOST] - coordinators[host_name] = {} + return True - proxmox: ProxmoxAPI = proxmox_client.get_api_client() - for node_config in entry.data[CONF_NODES]: - node_name = node_config[CONF_NODE] - node_coordinators = coordinators[host_name][node_name] = {} +async def async_migrate_entry(hass: HomeAssistant, entry: ProxmoxConfigEntry) -> bool: + """Migrate old config entries.""" - try: - vms, containers = await hass.async_add_executor_job( - _get_vms_containers, proxmox, node_config + # Migration for only the old binary sensors to new unique_id format + if entry.version < 2: + ent_reg = er.async_get(hass) + for entity_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id): + new_unique_id = ( + f"{entry.entry_id}_{entity_entry.unique_id.split('_')[-2]}_status" ) - except (ResourceException, requests.exceptions.ConnectionError) as err: - LOGGER.error("Unable to get vms/containers for node %s: %s", node_name, err) - continue - for vm in vms: - coordinator = _create_coordinator_container_vm( - hass, entry, proxmox, host_name, node_name, vm["vmid"], TYPE_VM + _LOGGER.debug( + "Migrating entity %s from old unique_id %s to new unique_id %s", + entity_entry.entity_id, + entity_entry.unique_id, + new_unique_id, ) - await coordinator.async_config_entry_first_refresh() - - node_coordinators[vm["vmid"]] = coordinator - - for container in containers: - coordinator = _create_coordinator_container_vm( - hass, - entry, - proxmox, - host_name, - node_name, - container["vmid"], - TYPE_CONTAINER, + ent_reg.async_update_entity( + entity_entry.entity_id, new_unique_id=new_unique_id ) - await coordinator.async_config_entry_first_refresh() - - node_coordinators[container["vmid"]] = coordinator - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + hass.config_entries.async_update_entry(entry, version=2) return True -def _get_vms_containers( - proxmox: ProxmoxAPI, - node_config: dict[str, Any], -) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: - """Get vms and containers for a node.""" - vms = proxmox.nodes(node_config[CONF_NODE]).qemu.get() - containers = proxmox.nodes(node_config[CONF_NODE]).lxc.get() - assert vms is not None and containers is not None - return vms, containers - - -def _create_coordinator_container_vm( - hass: HomeAssistant, - entry: ProxmoxConfigEntry, - proxmox: ProxmoxAPI, - host_name: str, - node_name: str, - vm_id: int, - vm_type: int, -) -> DataUpdateCoordinator[dict[str, Any] | None]: - """Create and return a DataUpdateCoordinator for a vm/container.""" - - async def async_update_data() -> dict[str, Any] | None: - """Call the api and handle the response.""" - - def poll_api() -> dict[str, Any] | None: - """Call the api.""" - return call_api_container_vm(proxmox, node_name, vm_id, vm_type) - - vm_status = await hass.async_add_executor_job(poll_api) - - if vm_status is None: - LOGGER.warning( - "Vm/Container %s unable to be found in node %s", vm_id, node_name - ) - return None - - return parse_api_container_vm(vm_status) - - return DataUpdateCoordinator( - hass, - LOGGER, - config_entry=entry, - name=f"proxmox_coordinator_{host_name}_{node_name}_{vm_id}", - update_method=async_update_data, - update_interval=timedelta(seconds=UPDATE_INTERVAL), - ) - - async def async_unload_entry(hass: HomeAssistant, entry: ProxmoxConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/proxmoxve/binary_sensor.py b/homeassistant/components/proxmoxve/binary_sensor.py index abc3ced24f012d..b69048ef3ebea8 100644 --- a/homeassistant/components/proxmoxve/binary_sensor.py +++ b/homeassistant/components/proxmoxve/binary_sensor.py @@ -2,20 +2,76 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) -from homeassistant.const import CONF_HOST +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import ProxmoxConfigEntry -from .const import CONF_CONTAINERS, CONF_NODE, CONF_NODES, CONF_VMS -from .entity import ProxmoxEntity +from .const import NODE_ONLINE, VM_CONTAINER_RUNNING +from .coordinator import ProxmoxConfigEntry, ProxmoxNodeData +from .entity import ProxmoxContainerEntity, ProxmoxNodeEntity, ProxmoxVMEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class ProxmoxContainerBinarySensorEntityDescription(BinarySensorEntityDescription): + """Class to hold Proxmox container binary sensor description.""" + + state_fn: Callable[[dict[str, Any]], bool | None] + + +@dataclass(frozen=True, kw_only=True) +class ProxmoxVMBinarySensorEntityDescription(BinarySensorEntityDescription): + """Class to hold Proxmox endpoint binary sensor description.""" + + state_fn: Callable[[dict[str, Any]], bool | None] + + +@dataclass(frozen=True, kw_only=True) +class ProxmoxNodeBinarySensorEntityDescription(BinarySensorEntityDescription): + """Class to hold Proxmox node binary sensor description.""" + + state_fn: Callable[[ProxmoxNodeData], bool | None] + + +NODE_SENSORS: tuple[ProxmoxNodeBinarySensorEntityDescription, ...] = ( + ProxmoxNodeBinarySensorEntityDescription( + key="status", + translation_key="status", + state_fn=lambda data: data.node["status"] == NODE_ONLINE, + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + +CONTAINER_SENSORS: tuple[ProxmoxContainerBinarySensorEntityDescription, ...] = ( + ProxmoxContainerBinarySensorEntityDescription( + key="status", + translation_key="status", + state_fn=lambda data: data["status"] == VM_CONTAINER_RUNNING, + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + +VM_SENSORS: tuple[ProxmoxVMBinarySensorEntityDescription, ...] = ( + ProxmoxVMBinarySensorEntityDescription( + key="status", + translation_key="status", + state_fn=lambda data: data["status"] == VM_CONTAINER_RUNNING, + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) async def async_setup_entry( @@ -23,78 +79,96 @@ async def async_setup_entry( entry: ProxmoxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up binary sensors.""" - sensors = [] + """Set up Proxmox VE binary sensors.""" + coordinator = entry.runtime_data + + def _async_add_new_nodes(nodes: list[ProxmoxNodeData]) -> None: + """Add new node binary sensors.""" + async_add_entities( + ProxmoxNodeBinarySensor(coordinator, entity_description, node) + for node in nodes + for entity_description in NODE_SENSORS + ) - host_name = entry.data[CONF_HOST] - host_name_coordinators = entry.runtime_data[host_name] + def _async_add_new_vms( + vms: list[tuple[ProxmoxNodeData, dict[str, Any]]], + ) -> None: + """Add new VM binary sensors.""" + async_add_entities( + ProxmoxVMBinarySensor(coordinator, entity_description, vm, node_data) + for (node_data, vm) in vms + for entity_description in VM_SENSORS + ) - for node_config in entry.data[CONF_NODES]: - node_name = node_config[CONF_NODE] + def _async_add_new_containers( + containers: list[tuple[ProxmoxNodeData, dict[str, Any]]], + ) -> None: + """Add new container binary sensors.""" + async_add_entities( + ProxmoxContainerBinarySensor( + coordinator, entity_description, container, node_data + ) + for (node_data, container) in containers + for entity_description in CONTAINER_SENSORS + ) - for dev_id in node_config[CONF_VMS] + node_config[CONF_CONTAINERS]: - coordinator = host_name_coordinators[node_name][dev_id] + coordinator.new_nodes_callbacks.append(_async_add_new_nodes) + coordinator.new_vms_callbacks.append(_async_add_new_vms) + coordinator.new_containers_callbacks.append(_async_add_new_containers) - if TYPE_CHECKING: - assert coordinator.data is not None - name = coordinator.data["name"] - sensor = create_binary_sensor( - coordinator, host_name, node_name, dev_id, name - ) - sensors.append(sensor) - - async_add_entities(sensors) - - -def create_binary_sensor( - coordinator, - host_name: str, - node_name: str, - vm_id: int, - name: str, -) -> ProxmoxBinarySensor: - """Create a binary sensor based on the given data.""" - return ProxmoxBinarySensor( - coordinator=coordinator, - unique_id=f"proxmox_{node_name}_{vm_id}_running", - name=f"{node_name}_{name}", - icon="", - host_name=host_name, - node_name=node_name, - vm_id=vm_id, + _async_add_new_nodes( + [ + node_data + for node_data in coordinator.data.values() + if node_data.node["node"] in coordinator.known_nodes + ] + ) + _async_add_new_vms( + [ + (node_data, vm_data) + for node_data in coordinator.data.values() + for vmid, vm_data in node_data.vms.items() + if (node_data.node["node"], vmid) in coordinator.known_vms + ] + ) + _async_add_new_containers( + [ + (node_data, container_data) + for node_data in coordinator.data.values() + for vmid, container_data in node_data.containers.items() + if (node_data.node["node"], vmid) in coordinator.known_containers + ] ) -class ProxmoxBinarySensor(ProxmoxEntity, BinarySensorEntity): - """A binary sensor for reading Proxmox VE data.""" - - _attr_device_class = BinarySensorDeviceClass.RUNNING +class ProxmoxNodeBinarySensor(ProxmoxNodeEntity, BinarySensorEntity): + """A binary sensor for reading Proxmox VE node data.""" - def __init__( - self, - coordinator: DataUpdateCoordinator, - unique_id: str, - name: str, - icon: str, - host_name: str, - node_name: str, - vm_id: int, - ) -> None: - """Create the binary sensor for vms or containers.""" - super().__init__( - coordinator, unique_id, name, icon, host_name, node_name, vm_id - ) + entity_description: ProxmoxNodeBinarySensorEntityDescription @property def is_on(self) -> bool | None: - """Return the state of the binary sensor.""" - if (data := self.coordinator.data) is None: - return None + """Return true if the binary sensor is on.""" + return self.entity_description.state_fn(self.coordinator.data[self.device_name]) + + +class ProxmoxVMBinarySensor(ProxmoxVMEntity, BinarySensorEntity): + """Representation of a Proxmox VM binary sensor.""" - return data["status"] == "running" + entity_description: ProxmoxVMBinarySensorEntityDescription @property - def available(self) -> bool: - """Return sensor availability.""" + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.entity_description.state_fn(self.vm_data) + - return super().available and self.coordinator.data is not None +class ProxmoxContainerBinarySensor(ProxmoxContainerEntity, BinarySensorEntity): + """Representation of a Proxmox Container binary sensor.""" + + entity_description: ProxmoxContainerBinarySensorEntityDescription + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.entity_description.state_fn(self.container_data) diff --git a/homeassistant/components/proxmoxve/button.py b/homeassistant/components/proxmoxve/button.py new file mode 100644 index 00000000000000..69e7e1732b2a16 --- /dev/null +++ b/homeassistant/components/proxmoxve/button.py @@ -0,0 +1,318 @@ +"""Button platform for Proxmox VE.""" + +from __future__ import annotations + +from abc import abstractmethod +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from proxmoxer import AuthenticationError +from proxmoxer.core import ResourceException +import requests +from requests.exceptions import ConnectTimeout, SSLError + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import ProxmoxConfigEntry, ProxmoxCoordinator, ProxmoxNodeData +from .entity import ProxmoxContainerEntity, ProxmoxNodeEntity, ProxmoxVMEntity +from .helpers import is_granted + + +@dataclass(frozen=True, kw_only=True) +class ProxmoxNodeButtonNodeEntityDescription(ButtonEntityDescription): + """Class to hold Proxmox node button description.""" + + press_action: Callable[[ProxmoxCoordinator, str], None] + + +@dataclass(frozen=True, kw_only=True) +class ProxmoxVMButtonEntityDescription(ButtonEntityDescription): + """Class to hold Proxmox VM button description.""" + + press_action: Callable[[ProxmoxCoordinator, str, int], None] + + +@dataclass(frozen=True, kw_only=True) +class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription): + """Class to hold Proxmox container button description.""" + + press_action: Callable[[ProxmoxCoordinator, str, int], None] + + +NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = ( + ProxmoxNodeButtonNodeEntityDescription( + key="reboot", + press_action=lambda coordinator, node: coordinator.proxmox.nodes( + node + ).status.post(command="reboot"), + entity_category=EntityCategory.CONFIG, + device_class=ButtonDeviceClass.RESTART, + ), + ProxmoxNodeButtonNodeEntityDescription( + key="shutdown", + translation_key="shutdown", + press_action=lambda coordinator, node: coordinator.proxmox.nodes( + node + ).status.post(command="shutdown"), + entity_category=EntityCategory.CONFIG, + ), + ProxmoxNodeButtonNodeEntityDescription( + key="start_all", + translation_key="start_all", + press_action=lambda coordinator, node: coordinator.proxmox.nodes( + node + ).startall.post(), + entity_category=EntityCategory.CONFIG, + ), + ProxmoxNodeButtonNodeEntityDescription( + key="stop_all", + translation_key="stop_all", + press_action=lambda coordinator, node: coordinator.proxmox.nodes( + node + ).stopall.post(), + entity_category=EntityCategory.CONFIG, + ), +) + +VM_BUTTONS: tuple[ProxmoxVMButtonEntityDescription, ...] = ( + ProxmoxVMButtonEntityDescription( + key="start", + translation_key="start", + press_action=lambda coordinator, node, vmid: ( + coordinator.proxmox.nodes(node).qemu(vmid).status.start.post() + ), + entity_category=EntityCategory.CONFIG, + ), + ProxmoxVMButtonEntityDescription( + key="stop", + translation_key="stop", + press_action=lambda coordinator, node, vmid: ( + coordinator.proxmox.nodes(node).qemu(vmid).status.stop.post() + ), + entity_category=EntityCategory.CONFIG, + ), + ProxmoxVMButtonEntityDescription( + key="restart", + press_action=lambda coordinator, node, vmid: ( + coordinator.proxmox.nodes(node).qemu(vmid).status.reboot.post() + ), + entity_category=EntityCategory.CONFIG, + device_class=ButtonDeviceClass.RESTART, + ), + ProxmoxVMButtonEntityDescription( + key="hibernate", + translation_key="hibernate", + press_action=lambda coordinator, node, vmid: ( + coordinator.proxmox.nodes(node).qemu(vmid).status.hibernate.post() + ), + entity_category=EntityCategory.CONFIG, + ), + ProxmoxVMButtonEntityDescription( + key="reset", + translation_key="reset", + press_action=lambda coordinator, node, vmid: ( + coordinator.proxmox.nodes(node).qemu(vmid).status.reset.post() + ), + entity_category=EntityCategory.CONFIG, + ), +) + +CONTAINER_BUTTONS: tuple[ProxmoxContainerButtonEntityDescription, ...] = ( + ProxmoxContainerButtonEntityDescription( + key="start", + translation_key="start", + press_action=lambda coordinator, node, vmid: ( + coordinator.proxmox.nodes(node).lxc(vmid).status.start.post() + ), + entity_category=EntityCategory.CONFIG, + ), + ProxmoxContainerButtonEntityDescription( + key="stop", + translation_key="stop", + press_action=lambda coordinator, node, vmid: ( + coordinator.proxmox.nodes(node).lxc(vmid).status.stop.post() + ), + entity_category=EntityCategory.CONFIG, + ), + ProxmoxContainerButtonEntityDescription( + key="restart", + press_action=lambda coordinator, node, vmid: ( + coordinator.proxmox.nodes(node).lxc(vmid).status.reboot.post() + ), + entity_category=EntityCategory.CONFIG, + device_class=ButtonDeviceClass.RESTART, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ProxmoxConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up ProxmoxVE buttons.""" + coordinator = entry.runtime_data + + def _async_add_new_nodes(nodes: list[ProxmoxNodeData]) -> None: + """Add new node buttons.""" + async_add_entities( + ProxmoxNodeButtonEntity(coordinator, entity_description, node) + for node in nodes + for entity_description in NODE_BUTTONS + ) + + def _async_add_new_vms( + vms: list[tuple[ProxmoxNodeData, dict[str, Any]]], + ) -> None: + """Add new VM buttons.""" + async_add_entities( + ProxmoxVMButtonEntity(coordinator, entity_description, vm, node_data) + for (node_data, vm) in vms + for entity_description in VM_BUTTONS + ) + + def _async_add_new_containers( + containers: list[tuple[ProxmoxNodeData, dict[str, Any]]], + ) -> None: + """Add new container buttons.""" + async_add_entities( + ProxmoxContainerButtonEntity( + coordinator, entity_description, container, node_data + ) + for (node_data, container) in containers + for entity_description in CONTAINER_BUTTONS + ) + + coordinator.new_nodes_callbacks.append(_async_add_new_nodes) + coordinator.new_vms_callbacks.append(_async_add_new_vms) + coordinator.new_containers_callbacks.append(_async_add_new_containers) + + _async_add_new_nodes( + [ + node_data + for node_data in coordinator.data.values() + if node_data.node["node"] in coordinator.known_nodes + ] + ) + _async_add_new_vms( + [ + (node_data, vm_data) + for node_data in coordinator.data.values() + for vmid, vm_data in node_data.vms.items() + if (node_data.node["node"], vmid) in coordinator.known_vms + ] + ) + _async_add_new_containers( + [ + (node_data, container_data) + for node_data in coordinator.data.values() + for vmid, container_data in node_data.containers.items() + if (node_data.node["node"], vmid) in coordinator.known_containers + ] + ) + + +class ProxmoxBaseButton(ButtonEntity): + """Common base for Proxmox buttons. Basically to ensure the async_press logic isn't duplicated.""" + + entity_description: ButtonEntityDescription + coordinator: ProxmoxCoordinator + + @abstractmethod + async def _async_press_call(self) -> None: + """Abstract method used per Proxmox button class.""" + + async def async_press(self) -> None: + """Trigger the Proxmox button press service.""" + try: + await self._async_press_call() + except AuthenticationError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect_no_details", + ) from err + except SSLError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_auth_no_details", + ) from err + except ConnectTimeout as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="timeout_connect_no_details", + ) from err + except (ResourceException, requests.exceptions.ConnectionError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_error_no_details", + ) from err + + +class ProxmoxNodeButtonEntity(ProxmoxNodeEntity, ProxmoxBaseButton): + """Represents a Proxmox Node button entity.""" + + entity_description: ProxmoxNodeButtonNodeEntityDescription + + async def _async_press_call(self) -> None: + """Execute the node button action via executor.""" + if not is_granted(self.coordinator.permissions, p_type="nodes"): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_permission_node_power", + ) + await self.hass.async_add_executor_job( + self.entity_description.press_action, + self.coordinator, + self._node_data.node["node"], + ) + + +class ProxmoxVMButtonEntity(ProxmoxVMEntity, ProxmoxBaseButton): + """Represents a Proxmox VM button entity.""" + + entity_description: ProxmoxVMButtonEntityDescription + + async def _async_press_call(self) -> None: + """Execute the VM button action via executor.""" + if not is_granted(self.coordinator.permissions, p_type="vms"): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_permission_vm_lxc_power", + ) + await self.hass.async_add_executor_job( + self.entity_description.press_action, + self.coordinator, + self._node_name, + self.vm_data["vmid"], + ) + + +class ProxmoxContainerButtonEntity(ProxmoxContainerEntity, ProxmoxBaseButton): + """Represents a Proxmox Container button entity.""" + + entity_description: ProxmoxContainerButtonEntityDescription + + async def _async_press_call(self) -> None: + """Execute the container button action via executor.""" + # Container power actions fall under vms + if not is_granted(self.coordinator.permissions, p_type="vms"): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_permission_vm_lxc_power", + ) + await self.hass.async_add_executor_job( + self.entity_description.press_action, + self.coordinator, + self._node_name, + self.container_data["vmid"], + ) diff --git a/homeassistant/components/proxmoxve/common.py b/homeassistant/components/proxmoxve/common.py index 6dc59cb8dd041c..59203ce1e6072d 100644 --- a/homeassistant/components/proxmoxve/common.py +++ b/homeassistant/components/proxmoxve/common.py @@ -1,88 +1,16 @@ -"""Commons for Proxmox VE integration.""" - -from __future__ import annotations +"""Common methods for Proxmox VE integration.""" from typing import Any -from proxmoxer import ProxmoxAPI -from proxmoxer.core import ResourceException -import requests.exceptions - -from .const import TYPE_CONTAINER, TYPE_VM - - -class ProxmoxClient: - """A wrapper for the proxmoxer ProxmoxAPI client.""" - - _proxmox: ProxmoxAPI - - def __init__( - self, - host: str, - port: int, - user: str, - realm: str, - password: str, - verify_ssl: bool, - ) -> None: - """Initialize the ProxmoxClient.""" - - self._host = host - self._port = port - self._user = user - self._realm = realm - self._password = password - self._verify_ssl = verify_ssl - - def build_client(self) -> None: - """Construct the ProxmoxAPI client. - - Allows inserting the realm within the `user` value. - """ - - if "@" in self._user: - user_id = self._user - else: - user_id = f"{self._user}@{self._realm}" - - self._proxmox = ProxmoxAPI( - self._host, - port=self._port, - user=user_id, - password=self._password, - verify_ssl=self._verify_ssl, - ) - - def get_api_client(self) -> ProxmoxAPI: - """Return the ProxmoxAPI client.""" - return self._proxmox - - -def parse_api_container_vm(status: dict[str, Any]) -> dict[str, Any]: - """Get the container or vm api data and return it formatted in a dictionary. - - It is implemented in this way to allow for more data to be added for sensors - in the future. - """ - - return {"status": status["status"], "name": status["name"]} - +from homeassistant.const import CONF_USERNAME -def call_api_container_vm( - proxmox: ProxmoxAPI, - node_name: str, - vm_id: int, - machine_type: int, -) -> dict[str, Any] | None: - """Make proper api calls.""" - status = None +from .const import CONF_REALM - try: - if machine_type == TYPE_VM: - status = proxmox.nodes(node_name).qemu(vm_id).status.current.get() - elif machine_type == TYPE_CONTAINER: - status = proxmox.nodes(node_name).lxc(vm_id).status.current.get() - except ResourceException, requests.exceptions.ConnectionError: - return None - return status +def sanitize_userid(data: dict[str, Any]) -> str: + """Sanitize the user ID.""" + return ( + data[CONF_USERNAME] + if "@" in data[CONF_USERNAME] + else f"{data[CONF_USERNAME]}@{data[CONF_REALM]}" + ) diff --git a/homeassistant/components/proxmoxve/config_flow.py b/homeassistant/components/proxmoxve/config_flow.py index 50d1778c4b188d..369cd96e7316b7 100644 --- a/homeassistant/components/proxmoxve/config_flow.py +++ b/homeassistant/components/proxmoxve/config_flow.py @@ -7,6 +7,7 @@ from typing import Any from proxmoxer import AuthenticationError, ProxmoxAPI +from proxmoxer.core import ResourceException import requests from requests.exceptions import ConnectTimeout, SSLError import voluptuous as vol @@ -22,7 +23,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv -from .common import ResourceException +from .common import sanitize_userid from .const import ( CONF_CONTAINERS, CONF_NODE, @@ -48,22 +49,13 @@ ) -def _sanitize_userid(data: dict[str, Any]) -> str: - """Sanitize the user ID.""" - return ( - data[CONF_USERNAME] - if "@" in data[CONF_USERNAME] - else f"{data[CONF_USERNAME]}@{data[CONF_REALM]}" - ) - - def _get_nodes_data(data: dict[str, Any]) -> list[dict[str, Any]]: """Validate the user input and fetch data (sync, for executor).""" try: client = ProxmoxAPI( data[CONF_HOST], port=data[CONF_PORT], - user=_sanitize_userid(data), + user=sanitize_userid(data), password=data[CONF_PASSWORD], verify_ssl=data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), ) @@ -74,18 +66,20 @@ def _get_nodes_data(data: dict[str, Any]) -> list[dict[str, Any]]: raise ProxmoxSSLError from err except ConnectTimeout as err: raise ProxmoxConnectTimeout from err - except (ResourceException, requests.exceptions.ConnectionError) as err: + except ResourceException as err: raise ProxmoxNoNodesFound from err - - _LOGGER.debug("Proxmox nodes: %s", nodes) + except requests.exceptions.ConnectionError as err: + raise ProxmoxConnectionError from err nodes_data: list[dict[str, Any]] = [] for node in nodes: try: vms = client.nodes(node["node"]).qemu.get() containers = client.nodes(node["node"]).lxc.get() - except (ResourceException, requests.exceptions.ConnectionError) as err: + except ResourceException as err: raise ProxmoxNoNodesFound from err + except requests.exceptions.ConnectionError as err: + raise ProxmoxConnectionError from err nodes_data.append( { @@ -102,7 +96,7 @@ def _get_nodes_data(data: dict[str, Any]) -> list[dict[str, Any]]: class ProxmoxveConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Proxmox VE.""" - VERSION = 1 + VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -199,18 +193,30 @@ async def _validate_input( """Validate the user input. Return nodes data and/or errors.""" errors: dict[str, str] = {} proxmox_nodes: list[dict[str, Any]] = [] + err: ProxmoxError | None = None try: proxmox_nodes = await self.hass.async_add_executor_job( _get_nodes_data, user_input ) - except ProxmoxConnectTimeout: + except ProxmoxConnectTimeout as exc: errors["base"] = "connect_timeout" - except ProxmoxAuthenticationError: + err = exc + except ProxmoxAuthenticationError as exc: errors["base"] = "invalid_auth" - except ProxmoxSSLError: + err = exc + except ProxmoxSSLError as exc: errors["base"] = "ssl_error" - except ProxmoxNoNodesFound: + err = exc + except ProxmoxNoNodesFound as exc: errors["base"] = "no_nodes_found" + err = exc + except ProxmoxConnectionError as exc: + errors["base"] = "cannot_connect" + err = exc + + if err is not None: + _LOGGER.debug("Error: %s: %s", errors["base"], err) + return proxmox_nodes, errors async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: @@ -229,6 +235,8 @@ async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResu return self.async_abort(reason="ssl_error") except ProxmoxNoNodesFound: return self.async_abort(reason="no_nodes_found") + except ProxmoxConnectionError: + return self.async_abort(reason="cannot_connect") return self.async_create_entry( title=import_data[CONF_HOST], @@ -236,17 +244,25 @@ async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResu ) -class ProxmoxNoNodesFound(HomeAssistantError): +class ProxmoxError(HomeAssistantError): + """Base class for Proxmox VE errors.""" + + +class ProxmoxNoNodesFound(ProxmoxError): """Error to indicate no nodes found.""" -class ProxmoxConnectTimeout(HomeAssistantError): +class ProxmoxConnectTimeout(ProxmoxError): """Error to indicate a connection timeout.""" -class ProxmoxSSLError(HomeAssistantError): +class ProxmoxSSLError(ProxmoxError): """Error to indicate an SSL error.""" -class ProxmoxAuthenticationError(HomeAssistantError): +class ProxmoxAuthenticationError(ProxmoxError): """Error to indicate an authentication error.""" + + +class ProxmoxConnectionError(ProxmoxError): + """Error to indicate a connection error.""" diff --git a/homeassistant/components/proxmoxve/const.py b/homeassistant/components/proxmoxve/const.py index da62f89069a918..eb7fe5f3484e74 100644 --- a/homeassistant/components/proxmoxve/const.py +++ b/homeassistant/components/proxmoxve/const.py @@ -7,6 +7,9 @@ CONF_VMS = "vms" CONF_CONTAINERS = "containers" +NODE_ONLINE = "online" +VM_CONTAINER_RUNNING = "running" + DEFAULT_PORT = 8006 DEFAULT_REALM = "pam" @@ -14,3 +17,5 @@ TYPE_VM = 0 TYPE_CONTAINER = 1 UPDATE_INTERVAL = 60 + +PERM_POWER = "VM.PowerMgmt" diff --git a/homeassistant/components/proxmoxve/coordinator.py b/homeassistant/components/proxmoxve/coordinator.py new file mode 100644 index 00000000000000..b73b244667ca2b --- /dev/null +++ b/homeassistant/components/proxmoxve/coordinator.py @@ -0,0 +1,259 @@ +"""Data Update Coordinator for Proxmox VE integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from datetime import timedelta +import logging +from typing import Any + +from proxmoxer import AuthenticationError, ProxmoxAPI +from proxmoxer.core import ResourceException +import requests +from requests.exceptions import ConnectTimeout, SSLError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .common import sanitize_userid +from .const import CONF_NODE, DEFAULT_VERIFY_SSL, DOMAIN + +type ProxmoxConfigEntry = ConfigEntry[ProxmoxCoordinator] + +DEFAULT_UPDATE_INTERVAL = timedelta(seconds=60) + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(slots=True, kw_only=True) +class ProxmoxNodeData: + """All resources for a single Proxmox node.""" + + node: dict[str, str] = field(default_factory=dict) + vms: dict[int, dict[str, Any]] = field(default_factory=dict) + containers: dict[int, dict[str, Any]] = field(default_factory=dict) + + +class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]): + """Data Update Coordinator for Proxmox VE integration.""" + + config_entry: ProxmoxConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: ProxmoxConfigEntry, + ) -> None: + """Initialize the Proxmox VE coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=DEFAULT_UPDATE_INTERVAL, + ) + self.proxmox: ProxmoxAPI + + self.known_nodes: set[str] = set() + self.known_vms: set[tuple[str, int]] = set() + self.known_containers: set[tuple[str, int]] = set() + self.permissions: dict[str, dict[str, int]] = {} + + self.new_nodes_callbacks: list[Callable[[list[ProxmoxNodeData]], None]] = [] + self.new_vms_callbacks: list[ + Callable[[list[tuple[ProxmoxNodeData, dict[str, Any]]]], None] + ] = [] + self.new_containers_callbacks: list[ + Callable[[list[tuple[ProxmoxNodeData, dict[str, Any]]]], None] + ] = [] + + async def _async_setup(self) -> None: + """Set up the coordinator.""" + try: + await self.hass.async_add_executor_job(self._init_proxmox) + except AuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={"error": repr(err)}, + ) from err + except SSLError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="ssl_error", + translation_placeholders={"error": repr(err)}, + ) from err + except ConnectTimeout as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="timeout_connect", + translation_placeholders={"error": repr(err)}, + ) from err + except ProxmoxServerError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="api_error_details", + translation_placeholders={"error": repr(err)}, + ) from err + except ProxmoxPermissionsError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="permissions_error", + ) from err + except ProxmoxNodesNotFoundError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="no_nodes_found", + ) from err + except requests.exceptions.ConnectionError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": repr(err)}, + ) from err + + 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 + ) + except AuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={"error": repr(err)}, + ) from err + except SSLError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="ssl_error", + translation_placeholders={"error": repr(err)}, + ) from err + except ConnectTimeout as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="timeout_connect", + translation_placeholders={"error": repr(err)}, + ) from err + except ResourceException as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="no_nodes_found", + ) from err + except requests.exceptions.ConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": repr(err)}, + ) from err + + data: dict[str, ProxmoxNodeData] = {} + for node, (vms, containers) in zip(nodes, vms_containers, strict=True): + data[node[CONF_NODE]] = ProxmoxNodeData( + node=node, + vms={int(vm["vmid"]): vm for vm in vms}, + containers={ + int(container["vmid"]): container for container in containers + }, + ) + + self._async_add_remove_nodes(data) + return data + + def _init_proxmox(self) -> None: + """Initialize ProxmoxAPI instance.""" + self.proxmox = ProxmoxAPI( + host=self.config_entry.data[CONF_HOST], + port=self.config_entry.data[CONF_PORT], + user=sanitize_userid(dict(self.config_entry.data)), + password=self.config_entry.data[CONF_PASSWORD], + verify_ssl=self.config_entry.data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), + ) + + try: + self.permissions = self.proxmox.access.permissions.get() or {} + except ResourceException as err: + if 400 <= err.status_code < 500: + raise ProxmoxPermissionsError from err + raise ProxmoxServerError from err + + try: + self.proxmox.nodes.get() + except ResourceException as err: + if 400 <= err.status_code < 500: + 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]]]] + ]: + """Fetch all nodes, and then proceed to the VMs and containers.""" + nodes = self.proxmox.nodes.get() or [] + vms_containers = [self._get_vms_containers(node) for node in nodes] + return nodes, vms_containers + + def _get_vms_containers( + self, + node: dict[str, Any], + ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """Get vms and containers for a node.""" + vms = self.proxmox.nodes(node[CONF_NODE]).qemu.get() or [] + containers = self.proxmox.nodes(node[CONF_NODE]).lxc.get() or [] + return vms, containers + + def _async_add_remove_nodes(self, data: dict[str, ProxmoxNodeData]) -> None: + """Add new nodes/VMs/containers, track removals.""" + current_nodes = set(data.keys()) + new_nodes = current_nodes - self.known_nodes + if new_nodes: + _LOGGER.debug("New nodes found: %s", new_nodes) + self.known_nodes.update(new_nodes) + + # And yes, track new VM's and containers as well + current_vms = { + (node_name, vmid) + for node_name, node_data in data.items() + for vmid in node_data.vms + } + new_vms = current_vms - self.known_vms + if new_vms: + _LOGGER.debug("New VMs found: %s", new_vms) + self.known_vms.update(new_vms) + + current_containers = { + (node_name, vmid) + for node_name, node_data in data.items() + for vmid in node_data.containers + } + new_containers = current_containers - self.known_containers + if new_containers: + _LOGGER.debug("New containers found: %s", new_containers) + self.known_containers.update(new_containers) + + +class ProxmoxSetupError(Exception): + """Base exception for Proxmox setup issues.""" + + +class ProxmoxNodesNotFoundError(ProxmoxSetupError): + """Raised when the API works but no nodes are visible.""" + + +class ProxmoxPermissionsError(ProxmoxSetupError): + """Raised when failing to retrieve permissions.""" + + +class ProxmoxServerError(ProxmoxSetupError): + """Raised when the Proxmox server returns an error.""" diff --git a/homeassistant/components/proxmoxve/diagnostics.py b/homeassistant/components/proxmoxve/diagnostics.py new file mode 100644 index 00000000000000..fad68fd17c57b4 --- /dev/null +++ b/homeassistant/components/proxmoxve/diagnostics.py @@ -0,0 +1,28 @@ +"""Diagnostics support for Proxmox VE.""" + +from __future__ import annotations + +from dataclasses import asdict +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 . import ProxmoxConfigEntry + +TO_REDACT = [CONF_USERNAME, CONF_PASSWORD, CONF_HOST] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ProxmoxConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a Proxmox VE config entry.""" + + return { + "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT), + "devices": { + node: asdict(node_data) + for node, node_data in config_entry.runtime_data.data.items() + }, + } diff --git a/homeassistant/components/proxmoxve/entity.py b/homeassistant/components/proxmoxve/entity.py index 5dfd264df2db5c..5684845391a6d3 100644 --- a/homeassistant/components/proxmoxve/entity.py +++ b/homeassistant/components/proxmoxve/entity.py @@ -1,39 +1,168 @@ """Proxmox parent entity class.""" -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from __future__ import annotations +from typing import Any -class ProxmoxEntity(CoordinatorEntity): - """Represents any entity created for the Proxmox VE platform.""" +from yarl import URL + +from homeassistant.const import CONF_HOST, CONF_PORT +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 ProxmoxCoordinator, ProxmoxNodeData + + +def _proxmox_base_url(coordinator: ProxmoxCoordinator) -> URL: + """Return the base URL for the Proxmox VE.""" + data = coordinator.config_entry.data + return URL.build( + scheme="https", + host=data[CONF_HOST], + port=data[CONF_PORT], + ) + + +class ProxmoxCoordinatorEntity(CoordinatorEntity[ProxmoxCoordinator]): + """Base class for Proxmox entities.""" + + _attr_has_entity_name = True + + +class ProxmoxNodeEntity(ProxmoxCoordinatorEntity): + """Represents any entity created for a Proxmox VE node.""" + + def __init__( + self, + coordinator: ProxmoxCoordinator, + entity_description: EntityDescription, + node_data: ProxmoxNodeData, + ) -> None: + """Initialize the Proxmox node entity.""" + super().__init__(coordinator) + self._node_data = node_data + self.device_id = node_data.node["id"] + self.device_name = node_data.node["node"] + self.entity_description = entity_description + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, f"{coordinator.config_entry.entry_id}_node_{self.device_id}") + }, + name=node_data.node.get("node", str(self.device_id)), + model="Node", + configuration_url=_proxmox_base_url(coordinator).with_fragment( + f"v1:0:=node/{node_data.node['node']}" + ), + ) + + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{node_data.node['id']}_{entity_description.key}" + + @property + def available(self) -> bool: + """Return if the device is available.""" + return super().available and self.device_name in self.coordinator.data + + +class ProxmoxVMEntity(ProxmoxCoordinatorEntity): + """Represents a VM entity.""" + + def __init__( + self, + coordinator: ProxmoxCoordinator, + entity_description: EntityDescription, + vm_data: dict[str, Any], + node_data: ProxmoxNodeData, + ) -> None: + """Initialize the Proxmox VM entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._vm_data = vm_data + self._node_name = node_data.node["node"] + self.device_id = vm_data["vmid"] + self.device_name = vm_data["name"] + + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, f"{coordinator.config_entry.entry_id}_vm_{self.device_id}") + }, + name=self.device_name, + model="VM", + configuration_url=_proxmox_base_url(coordinator).with_fragment( + f"v1:0:=qemu/{vm_data['vmid']}" + ), + via_device=( + DOMAIN, + f"{coordinator.config_entry.entry_id}_node_{node_data.node['id']}", + ), + ) + + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_id}_{entity_description.key}" + + @property + def available(self) -> bool: + """Return if the device is available.""" + return ( + super().available + and self._node_name in self.coordinator.data + and self.device_id in self.coordinator.data[self._node_name].vms + ) + + @property + def vm_data(self) -> dict[str, Any]: + """Return the VM data.""" + return self.coordinator.data[self._node_name].vms[self.device_id] + + +class ProxmoxContainerEntity(ProxmoxCoordinatorEntity): + """Represents a Container entity.""" def __init__( self, - coordinator: DataUpdateCoordinator, - unique_id: str, - name: str, - icon: str, - host_name: str, - node_name: str, - vm_id: int | None = None, + coordinator: ProxmoxCoordinator, + entity_description: EntityDescription, + container_data: dict[str, Any], + node_data: ProxmoxNodeData, ) -> None: - """Initialize the Proxmox entity.""" + """Initialize the Proxmox Container entity.""" super().__init__(coordinator) + self.entity_description = entity_description + self._container_data = container_data + self._node_name = node_data.node["node"] + self.device_id = container_data["vmid"] + self.device_name = container_data["name"] - self.coordinator = coordinator - self._attr_unique_id = unique_id - self._attr_name = name - self._host_name = host_name - self._attr_icon = icon - self._available = True - self._node_name = node_name - self._vm_id = vm_id + self._attr_device_info = DeviceInfo( + identifiers={ + ( + DOMAIN, + f"{coordinator.config_entry.entry_id}_container_{self.device_id}", + ) + }, + name=self.device_name, + model="Container", + configuration_url=_proxmox_base_url(coordinator).with_fragment( + f"v1:0:=lxc/{container_data['vmid']}" + ), + via_device=( + DOMAIN, + f"{coordinator.config_entry.entry_id}_node_{node_data.node['id']}", + ), + ) - self._state = None + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_id}_{entity_description.key}" @property def available(self) -> bool: - """Return True if entity is available.""" - return self.coordinator.last_update_success and self._available + """Return if the device is available.""" + return ( + super().available + and self._node_name in self.coordinator.data + and self.device_id in self.coordinator.data[self._node_name].containers + ) + + @property + def container_data(self) -> dict[str, Any]: + """Return the Container data.""" + return self.coordinator.data[self._node_name].containers[self.device_id] diff --git a/homeassistant/components/proxmoxve/helpers.py b/homeassistant/components/proxmoxve/helpers.py new file mode 100644 index 00000000000000..d9db1f4dedb04c --- /dev/null +++ b/homeassistant/components/proxmoxve/helpers.py @@ -0,0 +1,13 @@ +"""Helpers for Proxmox VE.""" + +from .const import PERM_POWER + + +def is_granted( + permissions: dict[str, dict[str, int]], + p_type: str = "vms", + permission: str = PERM_POWER, +) -> bool: + """Validate user permissions for the given type and permission.""" + path = f"/{p_type}" + return permissions.get(path, {}).get(permission) == 1 diff --git a/homeassistant/components/proxmoxve/icons.json b/homeassistant/components/proxmoxve/icons.json new file mode 100644 index 00000000000000..6d1a21c0284c7e --- /dev/null +++ b/homeassistant/components/proxmoxve/icons.json @@ -0,0 +1,83 @@ +{ + "entity": { + "button": { + "hibernate": { + "default": "mdi:power-sleep" + }, + "reset": { + "default": "mdi:restart" + }, + "start": { + "default": "mdi:play" + }, + "stop": { + "default": "mdi:stop" + } + }, + "sensor": { + "container_cpu": { + "default": "mdi:cpu-64-bit" + }, + "container_disk": { + "default": "mdi:harddisk" + }, + "container_max_cpu": { + "default": "mdi:cpu-64-bit" + }, + "container_max_disk": { + "default": "mdi:harddisk" + }, + "container_max_memory": { + "default": "mdi:memory" + }, + "container_memory": { + "default": "mdi:memory" + }, + "container_status": { + "default": "mdi:server" + }, + "node_cpu": { + "default": "mdi:cpu-64-bit" + }, + "node_disk": { + "default": "mdi:harddisk" + }, + "node_max_cpu": { + "default": "mdi:cpu-64-bit" + }, + "node_max_disk": { + "default": "mdi:harddisk" + }, + "node_max_memory": { + "default": "mdi:memory" + }, + "node_memory": { + "default": "mdi:memory" + }, + "node_status": { + "default": "mdi:server" + }, + "vm_cpu": { + "default": "mdi:cpu-64-bit" + }, + "vm_disk": { + "default": "mdi:harddisk" + }, + "vm_max_cpu": { + "default": "mdi:cpu-64-bit" + }, + "vm_max_disk": { + "default": "mdi:harddisk" + }, + "vm_max_memory": { + "default": "mdi:memory" + }, + "vm_memory": { + "default": "mdi:memory" + }, + "vm_status": { + "default": "mdi:server" + } + } + } +} diff --git a/homeassistant/components/proxmoxve/manifest.json b/homeassistant/components/proxmoxve/manifest.json index 35aad8b9b88e82..85ae5fb425d8cf 100644 --- a/homeassistant/components/proxmoxve/manifest.json +++ b/homeassistant/components/proxmoxve/manifest.json @@ -1,12 +1,12 @@ { "domain": "proxmoxve", "name": "Proxmox VE", - "codeowners": ["@jhollowe", "@Corbeno", "@erwindouna"], + "codeowners": ["@Corbeno", "@erwindouna", "@CoMPaTech"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/proxmoxve", "integration_type": "service", "iot_class": "local_polling", "loggers": ["proxmoxer"], "quality_scale": "legacy", - "requirements": ["proxmoxer==2.0.1"] + "requirements": ["proxmoxer==2.3.0"] } diff --git a/homeassistant/components/proxmoxve/sensor.py b/homeassistant/components/proxmoxve/sensor.py new file mode 100644 index 00000000000000..4222bea34267e4 --- /dev/null +++ b/homeassistant/components/proxmoxve/sensor.py @@ -0,0 +1,350 @@ +"""Sensor platform for Proxmox VE integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.sensor import ( + EntityCategory, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, + StateType, +) +from homeassistant.const import PERCENTAGE, UnitOfInformation +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import ProxmoxConfigEntry, ProxmoxNodeData +from .entity import ProxmoxContainerEntity, ProxmoxNodeEntity, ProxmoxVMEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class ProxmoxNodeSensorEntityDescription(SensorEntityDescription): + """Class to hold Proxmox node sensor description.""" + + value_fn: Callable[[ProxmoxNodeData], StateType] + + +@dataclass(frozen=True, kw_only=True) +class ProxmoxVMSensorEntityDescription(SensorEntityDescription): + """Class to hold Proxmox VM sensor description.""" + + value_fn: Callable[[dict[str, Any]], StateType] + + +@dataclass(frozen=True, kw_only=True) +class ProxmoxContainerSensorEntityDescription(SensorEntityDescription): + """Class to hold Proxmox container sensor description.""" + + value_fn: Callable[[dict[str, Any]], StateType] + + +NODE_SENSORS: tuple[ProxmoxNodeSensorEntityDescription, ...] = ( + ProxmoxNodeSensorEntityDescription( + key="node_cpu", + translation_key="node_cpu", + value_fn=lambda data: data.node["cpu"] * 100, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + ProxmoxNodeSensorEntityDescription( + key="node_max_cpu", + translation_key="node_max_cpu", + value_fn=lambda data: data.node["maxcpu"], + ), + ProxmoxNodeSensorEntityDescription( + key="node_disk", + translation_key="node_disk", + value_fn=lambda data: data.node["disk"], + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + ProxmoxNodeSensorEntityDescription( + key="node_max_disk", + translation_key="node_max_disk", + value_fn=lambda data: data.node["maxdisk"], + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + ProxmoxNodeSensorEntityDescription( + key="node_memory", + translation_key="node_memory", + value_fn=lambda data: data.node["mem"], + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + ProxmoxNodeSensorEntityDescription( + key="node_max_memory", + translation_key="node_max_memory", + value_fn=lambda data: data.node["maxmem"], + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + ProxmoxNodeSensorEntityDescription( + key="node_status", + translation_key="node_status", + value_fn=lambda data: data.node["status"], + device_class=SensorDeviceClass.ENUM, + options=["online", "offline"], + ), +) + +VM_SENSORS: tuple[ProxmoxVMSensorEntityDescription, ...] = ( + ProxmoxVMSensorEntityDescription( + key="vm_max_cpu", + translation_key="vm_max_cpu", + value_fn=lambda data: data["cpus"], + ), + ProxmoxVMSensorEntityDescription( + key="vm_cpu", + translation_key="vm_cpu", + value_fn=lambda data: data["cpu"] * 100, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + ProxmoxVMSensorEntityDescription( + key="vm_memory", + translation_key="vm_memory", + value_fn=lambda data: data["mem"], + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + ProxmoxVMSensorEntityDescription( + key="vm_max_memory", + translation_key="vm_max_memory", + value_fn=lambda data: data["maxmem"], + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + ProxmoxVMSensorEntityDescription( + key="vm_disk", + translation_key="vm_disk", + value_fn=lambda data: data["disk"], + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + ProxmoxVMSensorEntityDescription( + key="vm_max_disk", + translation_key="vm_max_disk", + value_fn=lambda data: data["maxdisk"], + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + ProxmoxVMSensorEntityDescription( + key="vm_status", + translation_key="vm_status", + value_fn=lambda data: data["status"], + device_class=SensorDeviceClass.ENUM, + options=["running", "stopped", "suspended"], + ), +) + +CONTAINER_SENSORS: tuple[ProxmoxContainerSensorEntityDescription, ...] = ( + ProxmoxContainerSensorEntityDescription( + key="container_max_cpu", + translation_key="container_max_cpu", + value_fn=lambda data: data["cpus"], + ), + ProxmoxContainerSensorEntityDescription( + key="container_cpu", + translation_key="container_cpu", + value_fn=lambda data: data["cpu"] * 100, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + ProxmoxContainerSensorEntityDescription( + key="container_memory", + translation_key="container_memory", + value_fn=lambda data: data["mem"], + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + ProxmoxContainerSensorEntityDescription( + key="container_max_memory", + translation_key="container_max_memory", + value_fn=lambda data: data["maxmem"], + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + ProxmoxContainerSensorEntityDescription( + key="container_disk", + translation_key="container_disk", + value_fn=lambda data: data["disk"], + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + ProxmoxContainerSensorEntityDescription( + key="container_max_disk", + translation_key="container_max_disk", + value_fn=lambda data: data["maxdisk"], + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + ProxmoxContainerSensorEntityDescription( + key="container_status", + translation_key="container_status", + value_fn=lambda data: data["status"], + device_class=SensorDeviceClass.ENUM, + options=["running", "stopped", "suspended"], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ProxmoxConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Proxmox VE sensors.""" + coordinator = entry.runtime_data + + def _async_add_new_nodes(nodes: list[ProxmoxNodeData]) -> None: + """Add new node sensors.""" + async_add_entities( + ProxmoxNodeSensor(coordinator, entity_description, node) + for node in nodes + for entity_description in NODE_SENSORS + ) + + def _async_add_new_vms( + vms: list[tuple[ProxmoxNodeData, dict[str, Any]]], + ) -> None: + """Add new VM sensors.""" + async_add_entities( + ProxmoxVMSensor(coordinator, entity_description, vm, node_data) + for (node_data, vm) in vms + for entity_description in VM_SENSORS + ) + + def _async_add_new_containers( + containers: list[tuple[ProxmoxNodeData, dict[str, Any]]], + ) -> None: + """Add new container sensors.""" + async_add_entities( + ProxmoxContainerSensor( + coordinator, entity_description, container, node_data + ) + for (node_data, container) in containers + for entity_description in CONTAINER_SENSORS + ) + + coordinator.new_nodes_callbacks.append(_async_add_new_nodes) + coordinator.new_vms_callbacks.append(_async_add_new_vms) + coordinator.new_containers_callbacks.append(_async_add_new_containers) + + _async_add_new_nodes( + [ + node_data + for node_data in coordinator.data.values() + if node_data.node["node"] in coordinator.known_nodes + ] + ) + _async_add_new_vms( + [ + (node_data, vm_data) + for node_data in coordinator.data.values() + for vmid, vm_data in node_data.vms.items() + if (node_data.node["node"], vmid) in coordinator.known_vms + ] + ) + _async_add_new_containers( + [ + (node_data, container_data) + for node_data in coordinator.data.values() + for vmid, container_data in node_data.containers.items() + if (node_data.node["node"], vmid) in coordinator.known_containers + ] + ) + + +class ProxmoxNodeSensor(ProxmoxNodeEntity, SensorEntity): + """Representation of a Proxmox VE node sensor.""" + + entity_description: ProxmoxNodeSensorEntityDescription + + @property + def native_value(self) -> StateType: + """Return the native value of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data[self.device_name]) + + +class ProxmoxVMSensor(ProxmoxVMEntity, SensorEntity): + """Represents a Proxmox VE VM sensor.""" + + entity_description: ProxmoxVMSensorEntityDescription + + @property + def native_value(self) -> StateType: + """Return the native value of the sensor.""" + return self.entity_description.value_fn(self.vm_data) + + +class ProxmoxContainerSensor(ProxmoxContainerEntity, SensorEntity): + """Represents a Proxmox VE container sensor.""" + + entity_description: ProxmoxContainerSensorEntityDescription + + @property + def native_value(self) -> StateType: + """Return the native value of the sensor.""" + return self.entity_description.value_fn(self.container_data) diff --git a/homeassistant/components/proxmoxve/strings.json b/homeassistant/components/proxmoxve/strings.json index 49d5aed4b2cc0c..1f0992fe6a7e72 100644 --- a/homeassistant/components/proxmoxve/strings.json +++ b/homeassistant/components/proxmoxve/strings.json @@ -32,6 +32,14 @@ "username": "[%key:common::config_flow::data::username%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, + "data_description": { + "host": "[%key:component::proxmoxve::config::step::user::data_description::host%]", + "password": "[%key:component::proxmoxve::config::step::user::data_description::password%]", + "port": "[%key:component::proxmoxve::config::step::user::data_description::port%]", + "realm": "[%key:component::proxmoxve::config::step::user::data_description::realm%]", + "username": "[%key:component::proxmoxve::config::step::user::data_description::username%]", + "verify_ssl": "[%key:component::proxmoxve::config::step::user::data_description::verify_ssl%]" + }, "description": "Use the following form to reconfigure your Proxmox VE server connection.", "title": "Reconfigure Proxmox VE integration" }, @@ -44,12 +52,174 @@ "username": "[%key:common::config_flow::data::username%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, + "data_description": { + "host": "The hostname or IP address of your Proxmox VE server", + "password": "The password for the Proxmox VE server", + "port": "The port of your Proxmox VE server (default: 8006)", + "realm": "The authentication realm for the Proxmox VE server (default: 'pam')", + "username": "The username for the Proxmox VE server", + "verify_ssl": "Whether to verify SSL certificates. Disable only if you have a self-signed certificate" + }, "description": "Enter your Proxmox VE server details to set up the integration.", "title": "Connect to Proxmox VE" } } }, + "entity": { + "binary_sensor": { + "status": { + "name": "Status" + } + }, + "button": { + "hibernate": { + "name": "Hibernate" + }, + "reset": { + "name": "Reset" + }, + "shutdown": { + "name": "Shutdown" + }, + "start": { + "name": "Start" + }, + "start_all": { + "name": "Start all" + }, + "stop": { + "name": "Stop" + }, + "stop_all": { + "name": "Stop all" + } + }, + "sensor": { + "container_cpu": { + "name": "CPU usage" + }, + "container_disk": { + "name": "Disk usage" + }, + "container_max_cpu": { + "name": "Max CPU" + }, + "container_max_disk": { + "name": "Max disk usage" + }, + "container_max_memory": { + "name": "Max memory usage" + }, + "container_memory": { + "name": "Memory usage" + }, + "container_status": { + "name": "Status", + "state": { + "running": "Running", + "stopped": "Stopped", + "suspended": "Suspended" + } + }, + "node_cpu": { + "name": "CPU usage" + }, + "node_disk": { + "name": "Disk usage" + }, + "node_max_cpu": { + "name": "Max CPU" + }, + "node_max_disk": { + "name": "Max disk usage" + }, + "node_max_memory": { + "name": "Max memory usage" + }, + "node_memory": { + "name": "Memory usage" + }, + "node_status": { + "name": "Status", + "state": { + "offline": "Offline", + "online": "Online" + } + }, + "vm_cpu": { + "name": "CPU usage" + }, + "vm_disk": { + "name": "Disk usage" + }, + "vm_max_cpu": { + "name": "Max CPU" + }, + "vm_max_disk": { + "name": "Max disk usage" + }, + "vm_max_memory": { + "name": "Max memory usage" + }, + "vm_memory": { + "name": "Memory usage" + }, + "vm_status": { + "name": "Status", + "state": { + "running": "Running", + "stopped": "Stopped", + "suspended": "Suspended" + } + } + } + }, + "exceptions": { + "api_error_details": { + "message": "An error occurred while communicating with the Proxmox VE instance: {error}" + }, + "api_error_no_details": { + "message": "An error occurred while communicating with the Proxmox VE instance." + }, + "cannot_connect": { + "message": "An error occurred while trying to connect to the Proxmox VE instance: {error}" + }, + "cannot_connect_no_details": { + "message": "Could not connect to the Proxmox VE instance." + }, + "invalid_auth": { + "message": "An error occurred while trying to authenticate: {error}" + }, + "invalid_auth_no_details": { + "message": "Authentication failed for the Proxmox VE instance." + }, + "no_nodes_found": { + "message": "No active nodes were found on the Proxmox VE server." + }, + "no_permission_node_power": { + "message": "The configured Proxmox VE user does not have permission to manage the power state of nodes. Please grant the user the 'VM.PowerMgmt' permission and try again." + }, + "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." + }, + "permissions_error": { + "message": "Failed to retrieve Proxmox VE permissions. Please check your credentials and try again." + }, + "ssl_error": { + "message": "An SSL error occurred: {error}" + }, + "timeout_connect": { + "message": "A timeout occurred while trying to connect to the Proxmox VE instance: {error}" + }, + "timeout_connect_no_details": { + "message": "A timeout occurred while trying to connect to the Proxmox VE instance." + } + }, "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::proxmoxve::issues::deprecated_yaml_import_issue_connect_timeout::title%]" + }, "deprecated_yaml_import_issue_connect_timeout": { "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a connection timeout occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.", "title": "The {integration_title} YAML configuration is being removed" diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index c586df030c13d1..dfdb172f6755d8 100644 --- a/homeassistant/components/proxy/manifest.json +++ b/homeassistant/components/proxy/manifest.json @@ -4,5 +4,5 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/proxy", "quality_scale": "legacy", - "requirements": ["Pillow==12.0.0"] + "requirements": ["Pillow==12.1.1"] } diff --git a/homeassistant/components/pulseaudio_loopback/switch.py b/homeassistant/components/pulseaudio_loopback/switch.py index 1974363a8e3858..cb7bd8ce654653 100644 --- a/homeassistant/components/pulseaudio_loopback/switch.py +++ b/homeassistant/components/pulseaudio_loopback/switch.py @@ -115,7 +115,7 @@ def name(self): return self._name @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" return self._module_idx is not None diff --git a/homeassistant/components/push/camera.py b/homeassistant/components/push/camera.py index 7c1d37712bb680..26c91bb6d29cb6 100644 --- a/homeassistant/components/push/camera.py +++ b/homeassistant/components/push/camera.py @@ -6,7 +6,7 @@ from collections import deque from datetime import timedelta import logging -from typing import cast +from typing import Any, cast from aiohttp import web import voluptuous as vol @@ -183,7 +183,7 @@ async def async_camera_image( return self._current_image @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return { name: value diff --git a/homeassistant/components/pyload/config_flow.py b/homeassistant/components/pyload/config_flow.py index 3262069c2f5333..a13dc1f94107e0 100644 --- a/homeassistant/components/pyload/config_flow.py +++ b/homeassistant/components/pyload/config_flow.py @@ -90,7 +90,7 @@ async def validate_input(hass: HomeAssistant, user_input: dict[str, Any]) -> Non password=user_input[CONF_PASSWORD], ) - await pyload.login() + await pyload.get_status() class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/pyload/coordinator.py b/homeassistant/components/pyload/coordinator.py index 7bb2b870520162..a69ba0c67dd88d 100644 --- a/homeassistant/components/pyload/coordinator.py +++ b/homeassistant/components/pyload/coordinator.py @@ -64,19 +64,12 @@ async def _async_update_data(self) -> PyLoadData: **await self.pyload.get_status(), free_space=await self.pyload.free_space(), ) - except InvalidAuth: - try: - await self.pyload.login() - except InvalidAuth as exc: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="setup_authentication_exception", - translation_placeholders={CONF_USERNAME: self.pyload.username}, - ) from exc - _LOGGER.debug( - "Unable to retrieve data due to cookie expiration, retrying after 20 seconds" - ) - return self.data + except InvalidAuth as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="setup_authentication_exception", + translation_placeholders={CONF_USERNAME: self.pyload.username}, + ) from e except CannotConnect as e: raise UpdateFailed( translation_domain=DOMAIN, @@ -92,7 +85,6 @@ async def _async_setup(self) -> None: """Set up the coordinator.""" try: - await self.pyload.login() self.version = await self.pyload.version() except CannotConnect as e: raise ConfigEntryNotReady( diff --git a/homeassistant/components/pyload/manifest.json b/homeassistant/components/pyload/manifest.json index feaa23af7dea73..fe36327cc75487 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==1.4.2"] + "requirements": ["PyLoadAPI==2.0.0"] } diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index 6cc68e531514ef..25cce8f09c4e36 100644 --- a/homeassistant/components/qrcode/manifest.json +++ b/homeassistant/components/qrcode/manifest.json @@ -6,5 +6,5 @@ "iot_class": "calculated", "loggers": ["pyzbar"], "quality_scale": "legacy", - "requirements": ["Pillow==12.0.0", "pyzbar==0.1.7"] + "requirements": ["Pillow==12.1.1", "pyzbar==0.1.7"] } diff --git a/homeassistant/components/qvr_pro/camera.py b/homeassistant/components/qvr_pro/camera.py index 38221f89cfd35c..6496ce304a78e6 100644 --- a/homeassistant/components/qvr_pro/camera.py +++ b/homeassistant/components/qvr_pro/camera.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import Any from pyqvrpro.client import QVRResponseError @@ -88,7 +89,7 @@ def brand(self): return self._brand @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Get the state attributes.""" return {"qvr_guid": self.guid} diff --git a/homeassistant/components/qwikswitch/entity.py b/homeassistant/components/qwikswitch/entity.py index e163b4708a78cb..b07d857a1f1643 100644 --- a/homeassistant/components/qwikswitch/entity.py +++ b/homeassistant/components/qwikswitch/entity.py @@ -51,7 +51,7 @@ def __init__(self, qsid, qsusb): super().__init__(qsid, self.device.name) @property - def is_on(self): + def is_on(self) -> bool: """Check if device is on (non-zero).""" return self.device.value > 0 diff --git a/homeassistant/components/qwikswitch/light.py b/homeassistant/components/qwikswitch/light.py index 0f91faeedc8fd5..9de959d7009751 100644 --- a/homeassistant/components/qwikswitch/light.py +++ b/homeassistant/components/qwikswitch/light.py @@ -30,7 +30,7 @@ class QSLight(QSToggleEntity, LightEntity): """Light based on a Qwikswitch relay/dimmer module.""" @property - def brightness(self): + def brightness(self) -> int | None: """Return the brightness of this light (0-255).""" return self.device.value if self.device.is_dimmer else None diff --git a/homeassistant/components/radio_browser/media_source.py b/homeassistant/components/radio_browser/media_source.py index e62fe0325ccb06..165d53860a458e 100644 --- a/homeassistant/components/radio_browser/media_source.py +++ b/homeassistant/components/radio_browser/media_source.py @@ -4,10 +4,11 @@ import mimetypes +from aiodns.error import DNSError import pycountry -from radios import FilterBy, Order, RadioBrowser, Station +from radios import FilterBy, Order, RadioBrowser, RadioBrowserError, Station -from homeassistant.components.media_player import MediaClass, MediaType +from homeassistant.components.media_player import BrowseError, MediaClass, MediaType from homeassistant.components.media_source import ( BrowseMediaSource, MediaSource, @@ -15,6 +16,7 @@ PlayMedia, Unresolvable, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.util.location import vincenty @@ -55,9 +57,20 @@ def radios(self) -> RadioBrowser: async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve selected Radio station to a streaming URL.""" - radios = self.radios - station = await radios.station(uuid=item.identifier) + if self.entry.state != ConfigEntryState.LOADED: + raise Unresolvable( + translation_domain=DOMAIN, + translation_key="config_entry_not_ready", + ) + radios = self.radios + try: + station = await radios.station(uuid=item.identifier) + except (DNSError, RadioBrowserError) as e: + raise Unresolvable( + translation_domain=DOMAIN, + translation_key="radio_browser_error", + ) from e if not station: raise Unresolvable("Radio station is no longer available") @@ -74,25 +87,37 @@ async def async_browse_media( item: MediaSourceItem, ) -> BrowseMediaSource: """Return media.""" + + if self.entry.state != ConfigEntryState.LOADED: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="config_entry_not_ready", + ) radios = self.radios - return BrowseMediaSource( - domain=DOMAIN, - identifier=None, - media_class=MediaClass.CHANNEL, - media_content_type=MediaType.MUSIC, - title=self.entry.title, - can_play=False, - can_expand=True, - children_media_class=MediaClass.DIRECTORY, - children=[ - *await self._async_build_popular(radios, item), - *await self._async_build_by_tag(radios, item), - *await self._async_build_by_language(radios, item), - *await self._async_build_local(radios, item), - *await self._async_build_by_country(radios, item), - ], - ) + try: + return BrowseMediaSource( + domain=DOMAIN, + identifier=None, + media_class=MediaClass.CHANNEL, + media_content_type=MediaType.MUSIC, + title=self.entry.title, + can_play=False, + can_expand=True, + children_media_class=MediaClass.DIRECTORY, + children=[ + *await self._async_build_popular(radios, item), + *await self._async_build_by_tag(radios, item), + *await self._async_build_by_language(radios, item), + *await self._async_build_local(radios, item), + *await self._async_build_by_country(radios, item), + ], + ) + except (DNSError, RadioBrowserError) as e: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="radio_browser_error", + ) from e @callback @staticmethod diff --git a/homeassistant/components/radio_browser/strings.json b/homeassistant/components/radio_browser/strings.json index 5dd0ad3dcf70d0..c1e99128ee170f 100644 --- a/homeassistant/components/radio_browser/strings.json +++ b/homeassistant/components/radio_browser/strings.json @@ -5,5 +5,13 @@ "description": "Do you want to add Radio Browser to Home Assistant?" } } + }, + "exceptions": { + "config_entry_not_ready": { + "message": "Radio Browser integration is not ready" + }, + "radio_browser_error": { + "message": "Error occurred while communicating with Radio Browser" + } } } diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index a6a5ffc65d90b7..7b29b8014598c0 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -2,11 +2,12 @@ from __future__ import annotations +import asyncio import logging from typing import Any import aiohttp -from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController +from pyrainbird.async_client import AsyncRainbirdController, create_controller from pyrainbird.exceptions import RainbirdApiException, RainbirdAuthException from homeassistant.const import ( @@ -26,7 +27,7 @@ from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.typing import ConfigType -from .const import CONF_SERIAL_NUMBER, DOMAIN +from .const import CONF_SERIAL_NUMBER, DOMAIN, TIMEOUT_SECONDS from .coordinator import ( RainbirdScheduleUpdateCoordinator, RainbirdUpdateCoordinator, @@ -77,13 +78,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: RainbirdConfigEntry) -> clientsession = async_create_clientsession() _async_register_clientsession_shutdown(hass, entry, clientsession) - controller = AsyncRainbirdController( - AsyncRainbirdClient( - clientsession, - entry.data[CONF_HOST], - entry.data[CONF_PASSWORD], - ) - ) + try: + async with asyncio.timeout(TIMEOUT_SECONDS): + controller = await create_controller( + clientsession, + entry.data[CONF_HOST], + entry.data[CONF_PASSWORD], + ) + except TimeoutError as err: + raise ConfigEntryNotReady from err + except RainbirdAuthException as err: + raise ConfigEntryAuthFailed from err + except RainbirdApiException as err: + raise ConfigEntryNotReady from err if not (await _async_fix_unique_id(hass, controller, entry)): return False diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py index 1390650ea022ee..18ce02da6b2025 100644 --- a/homeassistant/components/rainbird/config_flow.py +++ b/homeassistant/components/rainbird/config_flow.py @@ -7,7 +7,7 @@ import logging from typing import Any -from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController +from pyrainbird.async_client import create_controller from pyrainbird.data import WifiParams from pyrainbird.exceptions import RainbirdApiException, RainbirdAuthException import voluptuous as vol @@ -137,15 +137,9 @@ async def _test_connection( Raises a ConfigFlowError on failure. """ clientsession = async_create_clientsession() - controller = AsyncRainbirdController( - AsyncRainbirdClient( - clientsession, - host, - password, - ) - ) try: async with asyncio.timeout(TIMEOUT_SECONDS): + controller = await create_controller(clientsession, host, password) return await asyncio.gather( controller.get_serial_number(), controller.get_wifi_params(), diff --git a/homeassistant/components/rainbird/manifest.json b/homeassistant/components/rainbird/manifest.json index 93b4f21d7cbeba..9563d9b7268926 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.0.1"] + "requirements": ["pyrainbird==6.1.1"] } diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index 687de2a6d97d47..bb6f90c0356763 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -72,7 +72,7 @@ def __init__( ) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return state attributes.""" return {"zone": self._zone} @@ -115,6 +115,6 @@ async def async_turn_off(self, **kwargs: Any) -> None: await self.coordinator.async_request_refresh() @property - def is_on(self): + def is_on(self) -> bool: """Return true if switch is on.""" return self._zone in self.coordinator.data.active_zones diff --git a/homeassistant/components/raincloud/binary_sensor.py b/homeassistant/components/raincloud/binary_sensor.py index 84621aba99dc7b..240550827d4b5e 100644 --- a/homeassistant/components/raincloud/binary_sensor.py +++ b/homeassistant/components/raincloud/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DATA_RAINCLOUD, ICON_MAP +from .const import DATA_RAINCLOUD from .entity import RainCloudEntity _LOGGER = logging.getLogger(__name__) @@ -62,23 +62,20 @@ def setup_platform( class RainCloudBinarySensor(RainCloudEntity, BinarySensorEntity): """A sensor implementation for raincloud device.""" - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self._state - def update(self) -> None: """Get the latest data and updates the state.""" - _LOGGER.debug("Updating RainCloud sensor: %s", self._name) - self._state = getattr(self.data, self._sensor_type) + _LOGGER.debug("Updating RainCloud sensor: %s", self.name) + state = getattr(self.data, self._sensor_type) if self._sensor_type == "status": - self._state = self._state == "Online" + self._attr_is_on = state == "Online" + else: + self._attr_is_on = state @property - def icon(self): + def icon(self) -> str | None: """Return the icon of this device.""" if self._sensor_type == "is_watering": return "mdi:water" if self.is_on else "mdi:water-off" if self._sensor_type == "status": return "mdi:pipe" if self.is_on else "mdi:pipe-disconnected" - return ICON_MAP.get(self._sensor_type) + return super().icon diff --git a/homeassistant/components/raincloud/entity.py b/homeassistant/components/raincloud/entity.py index b45684ac72b96c..8aa7707e5f5a39 100644 --- a/homeassistant/components/raincloud/entity.py +++ b/homeassistant/components/raincloud/entity.py @@ -1,5 +1,7 @@ """Support for Melnor RainCloud sprinkler water timer.""" +from typing import Any + from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity @@ -37,13 +39,8 @@ def __init__(self, data, sensor_type): """Initialize the RainCloud entity.""" self.data = data self._sensor_type = sensor_type - self._name = f"{self.data.name} {KEY_MAP.get(self._sensor_type)}" - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name + self._attr_name = f"{self.data.name} {KEY_MAP.get(self._sensor_type)}" + self._attr_icon = ICON_MAP.get(self._sensor_type) async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -58,11 +55,6 @@ def _update_callback(self): self.schedule_update_ha_state(True) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return {"identifier": self.data.serial} - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON_MAP.get(self._sensor_type) diff --git a/homeassistant/components/raincloud/sensor.py b/homeassistant/components/raincloud/sensor.py index 8aaec605c04f4e..6804a7c3ccc365 100644 --- a/homeassistant/components/raincloud/sensor.py +++ b/homeassistant/components/raincloud/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import cast import voluptuous as vol @@ -17,7 +18,7 @@ from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DATA_RAINCLOUD, ICON_MAP +from .const import DATA_RAINCLOUD from .entity import RainCloudEntity _LOGGER = logging.getLogger(__name__) @@ -32,7 +33,7 @@ } ) -UNIT_OF_MEASUREMENT_MAP = { +UNIT_OF_MEASUREMENT_MAP: dict[str, str] = { "auto_watering": "", "battery": PERCENTAGE, "is_watering": "", @@ -71,28 +72,24 @@ class RainCloudSensor(RainCloudEntity, SensorEntity): """A sensor implementation for raincloud device.""" @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def native_unit_of_measurement(self): + def native_unit_of_measurement(self) -> str | None: """Return the units of measurement.""" return UNIT_OF_MEASUREMENT_MAP.get(self._sensor_type) def update(self) -> None: """Get the latest data and updates the states.""" - _LOGGER.debug("Updating RainCloud sensor: %s", self._name) + _LOGGER.debug("Updating RainCloud sensor: %s", self.name) if self._sensor_type == "battery": - self._state = self.data.battery + self._attr_native_value = self.data.battery else: - self._state = getattr(self.data, self._sensor_type) + self._attr_native_value = getattr(self.data, self._sensor_type) @property - def icon(self): + def icon(self) -> str | None: """Icon to use in the frontend, if any.""" - if self._sensor_type == "battery" and self._state is not None: + if self._sensor_type == "battery" and self.native_value is not None: return icon_for_battery_level( - battery_level=int(self._state), charging=False + battery_level=int(cast(float, self.native_value)), + charging=False, ) - return ICON_MAP.get(self._sensor_type) + return super().icon diff --git a/homeassistant/components/raincloud/switch.py b/homeassistant/components/raincloud/switch.py index babadcba676f42..23858ce2ad8174 100644 --- a/homeassistant/components/raincloud/switch.py +++ b/homeassistant/components/raincloud/switch.py @@ -68,18 +68,13 @@ def __init__(self, default_watering_timer, *args): super().__init__(*args) self._default_watering_timer = default_watering_timer - @property - def is_on(self): - """Return true if device is on.""" - return self._state - def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" if self._sensor_type == "manual_watering": self.data.watering_time = self._default_watering_timer elif self._sensor_type == "auto_watering": self.data.auto_watering = True - self._state = True + self._attr_is_on = True def turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" @@ -87,18 +82,18 @@ def turn_off(self, **kwargs: Any) -> None: self.data.watering_time = "off" elif self._sensor_type == "auto_watering": self.data.auto_watering = False - self._state = False + self._attr_is_on = False def update(self) -> None: """Update device state.""" - _LOGGER.debug("Updating RainCloud switch: %s", self._name) + _LOGGER.debug("Updating RainCloud switch: %s", self.name) if self._sensor_type == "manual_watering": - self._state = bool(self.data.watering_time) + self._attr_is_on = bool(self.data.watering_time) elif self._sensor_type == "auto_watering": - self._state = self.data.auto_watering + self._attr_is_on = self.data.auto_watering @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return { "default_manual_timer": self._default_watering_timer, diff --git a/homeassistant/components/rdw/__init__.py b/homeassistant/components/rdw/__init__.py index 6051576026b1e1..7a2cfbf6df3962 100644 --- a/homeassistant/components/rdw/__init__.py +++ b/homeassistant/components/rdw/__init__.py @@ -2,32 +2,19 @@ from __future__ import annotations -from vehicle import RDW, Vehicle - from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import CONF_LICENSE_PLATE, DOMAIN, LOGGER, SCAN_INTERVAL +from .const import DOMAIN +from .coordinator import RDWDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up RDW from a config entry.""" - session = async_get_clientsession(hass) - rdw = RDW(session=session, license_plate=entry.data[CONF_LICENSE_PLATE]) - - coordinator: DataUpdateCoordinator[Vehicle] = DataUpdateCoordinator( - hass, - LOGGER, - config_entry=entry, - name=f"{DOMAIN}_APK", - update_interval=SCAN_INTERVAL, - update_method=rdw.vehicle, - ) + coordinator = RDWDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/rdw/binary_sensor.py b/homeassistant/components/rdw/binary_sensor.py index 58e1c2e8237868..d407cfc1b87ee8 100644 --- a/homeassistant/components/rdw/binary_sensor.py +++ b/homeassistant/components/rdw/binary_sensor.py @@ -16,12 +16,10 @@ 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, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import RDWDataUpdateCoordinator @dataclass(frozen=True, kw_only=True) @@ -52,7 +50,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up RDW binary sensors based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: RDWDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( RDWBinarySensorEntity( coordinator=coordinator, @@ -64,7 +62,7 @@ async def async_setup_entry( class RDWBinarySensorEntity( - CoordinatorEntity[DataUpdateCoordinator[Vehicle]], BinarySensorEntity + CoordinatorEntity[RDWDataUpdateCoordinator], BinarySensorEntity ): """Defines an RDW binary sensor.""" @@ -74,7 +72,7 @@ class RDWBinarySensorEntity( def __init__( self, *, - coordinator: DataUpdateCoordinator[Vehicle], + coordinator: RDWDataUpdateCoordinator, description: RDWBinarySensorEntityDescription, ) -> None: """Initialize RDW binary sensor.""" diff --git a/homeassistant/components/rdw/coordinator.py b/homeassistant/components/rdw/coordinator.py new file mode 100644 index 00000000000000..2b9bb866790c71 --- /dev/null +++ b/homeassistant/components/rdw/coordinator.py @@ -0,0 +1,36 @@ +"""Data update coordinator for RDW.""" + +from __future__ import annotations + +from vehicle import RDW, 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 .const import CONF_LICENSE_PLATE, DOMAIN, LOGGER, SCAN_INTERVAL + + +class RDWDataUpdateCoordinator(DataUpdateCoordinator[Vehicle]): + """Class to manage fetching RDW data.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=f"{DOMAIN}_APK", + update_interval=SCAN_INTERVAL, + ) + self._rdw = RDW( + session=async_get_clientsession(hass), + license_plate=config_entry.data[CONF_LICENSE_PLATE], + ) + + async def _async_update_data(self) -> Vehicle: + """Fetch data from RDW.""" + return await self._rdw.vehicle() diff --git a/homeassistant/components/rdw/diagnostics.py b/homeassistant/components/rdw/diagnostics.py index f55bc33e0263fb..bf5f8fbd904467 100644 --- a/homeassistant/components/rdw/diagnostics.py +++ b/homeassistant/components/rdw/diagnostics.py @@ -4,19 +4,17 @@ from typing import Any -from vehicle import Vehicle - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN +from .coordinator import RDWDataUpdateCoordinator async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: DataUpdateCoordinator[Vehicle] = hass.data[DOMAIN][entry.entry_id] + coordinator: RDWDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] data: dict[str, Any] = coordinator.data.to_dict() return data diff --git a/homeassistant/components/rdw/sensor.py b/homeassistant/components/rdw/sensor.py index 4133082bcf4804..08e7d772d15f39 100644 --- a/homeassistant/components/rdw/sensor.py +++ b/homeassistant/components/rdw/sensor.py @@ -17,12 +17,10 @@ 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, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_LICENSE_PLATE, DOMAIN +from .coordinator import RDWDataUpdateCoordinator @dataclass(frozen=True, kw_only=True) @@ -54,7 +52,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up RDW sensors based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: RDWDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( RDWSensorEntity( coordinator=coordinator, @@ -65,7 +63,7 @@ async def async_setup_entry( ) -class RDWSensorEntity(CoordinatorEntity[DataUpdateCoordinator[Vehicle]], SensorEntity): +class RDWSensorEntity(CoordinatorEntity[RDWDataUpdateCoordinator], SensorEntity): """Defines an RDW sensor.""" entity_description: RDWSensorEntityDescription @@ -74,7 +72,7 @@ class RDWSensorEntity(CoordinatorEntity[DataUpdateCoordinator[Vehicle]], SensorE def __init__( self, *, - coordinator: DataUpdateCoordinator[Vehicle], + coordinator: RDWDataUpdateCoordinator, license_plate: str, description: RDWSensorEntityDescription, ) -> None: diff --git a/homeassistant/components/recollect_waste/__init__.py b/homeassistant/components/recollect_waste/__init__.py index 1710fb8c816be6..c805b49144090a 100644 --- a/homeassistant/components/recollect_waste/__init__.py +++ b/homeassistant/components/recollect_waste/__init__.py @@ -2,63 +2,22 @@ from __future__ import annotations -from datetime import date, timedelta from typing import Any -from aiorecollect.client import Client, PickupEvent -from aiorecollect.errors import RecollectError - from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import aiohttp_client, entity_registry as er -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers import entity_registry as er from .const import CONF_PLACE_ID, CONF_SERVICE_ID, DOMAIN, LOGGER - -DEFAULT_NAME = "recollect_waste" -DEFAULT_UPDATE_INTERVAL = timedelta(days=1) +from .coordinator import ReCollectWasteDataUpdateCoordinator PLATFORMS = [Platform.CALENDAR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up RainMachine as config entry.""" - session = aiohttp_client.async_get_clientsession(hass) - client = Client( - entry.data[CONF_PLACE_ID], entry.data[CONF_SERVICE_ID], session=session - ) - - async def async_get_pickup_events() -> list[PickupEvent]: - """Get the next pickup.""" - try: - # Retrieve today through to 35 days in the future, to get - # coverage across a full two months boundary so that no - # upcoming pickups are missed. The api.recollect.net base API - # call returns only the current month when no dates are passed. - # This ensures that data about when the next pickup is will be - # returned when the next pickup is the first day of the next month. - # Ex: Today is August 31st, tomorrow is a pickup on September 1st. - today = date.today() - return await client.async_get_pickup_events( - start_date=today, - end_date=today + timedelta(days=35), - ) - except RecollectError as err: - raise UpdateFailed( - f"Error while requesting data from ReCollect: {err}" - ) from err - - coordinator = DataUpdateCoordinator( - hass, - LOGGER, - config_entry=entry, - name=( - f"Place {entry.data[CONF_PLACE_ID]}, Service {entry.data[CONF_SERVICE_ID]}" - ), - update_interval=DEFAULT_UPDATE_INTERVAL, - update_method=async_get_pickup_events, - ) + """Set up ReCollect Waste as config entry.""" + coordinator = ReCollectWasteDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) @@ -77,7 +36,7 @@ async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload an RainMachine config entry.""" + """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) diff --git a/homeassistant/components/recollect_waste/calendar.py b/homeassistant/components/recollect_waste/calendar.py index 8145a93a2b7dd1..f057d1c3368543 100644 --- a/homeassistant/components/recollect_waste/calendar.py +++ b/homeassistant/components/recollect_waste/calendar.py @@ -10,9 +10,9 @@ 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 DataUpdateCoordinator from .const import DOMAIN +from .coordinator import ReCollectWasteDataUpdateCoordinator from .entity import ReCollectWasteEntity from .util import async_get_pickup_type_names @@ -40,9 +40,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ReCollect Waste sensors based on a config entry.""" - coordinator: DataUpdateCoordinator[list[PickupEvent]] = hass.data[DOMAIN][ - entry.entry_id - ] + coordinator: ReCollectWasteDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities([ReCollectWasteCalendar(coordinator, entry)]) @@ -55,7 +53,7 @@ class ReCollectWasteCalendar(ReCollectWasteEntity, CalendarEntity): def __init__( self, - coordinator: DataUpdateCoordinator[list[PickupEvent]], + coordinator: ReCollectWasteDataUpdateCoordinator, entry: ConfigEntry, ) -> None: """Initialize the ReCollect Waste entity.""" diff --git a/homeassistant/components/recollect_waste/coordinator.py b/homeassistant/components/recollect_waste/coordinator.py new file mode 100644 index 00000000000000..4a7e9d58b125e2 --- /dev/null +++ b/homeassistant/components/recollect_waste/coordinator.py @@ -0,0 +1,61 @@ +"""Data update coordinator for ReCollect Waste.""" + +from __future__ import annotations + +from datetime import date, timedelta + +from aiorecollect.client import Client, PickupEvent +from aiorecollect.errors import RecollectError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_PLACE_ID, CONF_SERVICE_ID, LOGGER + +DEFAULT_UPDATE_INTERVAL = timedelta(days=1) + + +class ReCollectWasteDataUpdateCoordinator(DataUpdateCoordinator[list[PickupEvent]]): + """Class to manage fetching ReCollect Waste data.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=( + f"Place {config_entry.data[CONF_PLACE_ID]}, " + f"Service {config_entry.data[CONF_SERVICE_ID]}" + ), + update_interval=DEFAULT_UPDATE_INTERVAL, + ) + self._client = Client( + config_entry.data[CONF_PLACE_ID], + config_entry.data[CONF_SERVICE_ID], + session=aiohttp_client.async_get_clientsession(hass), + ) + + async def _async_update_data(self) -> list[PickupEvent]: + """Fetch data from ReCollect.""" + try: + # Retrieve today through to 35 days in the future, to get + # coverage across a full two months boundary so that no + # upcoming pickups are missed. The api.recollect.net base API + # call returns only the current month when no dates are passed. + # This ensures that data about when the next pickup is will be + # returned when the next pickup is the first day of the next month. + # Ex: Today is August 31st, tomorrow is a pickup on September 1st. + today = date.today() + return await self._client.async_get_pickup_events( + start_date=today, + end_date=today + timedelta(days=35), + ) + except RecollectError as err: + raise UpdateFailed( + f"Error while requesting data from ReCollect: {err}" + ) from err diff --git a/homeassistant/components/recollect_waste/diagnostics.py b/homeassistant/components/recollect_waste/diagnostics.py index f1dbcdb406146f..a9007eb5d2c3c0 100644 --- a/homeassistant/components/recollect_waste/diagnostics.py +++ b/homeassistant/components/recollect_waste/diagnostics.py @@ -5,15 +5,13 @@ import dataclasses from typing import Any -from aiorecollect.client import PickupEvent - 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 homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import CONF_PLACE_ID, DOMAIN +from .coordinator import ReCollectWasteDataUpdateCoordinator CONF_AREA_NAME = "area_name" CONF_TITLE = "title" @@ -31,9 +29,7 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: DataUpdateCoordinator[list[PickupEvent]] = hass.data[DOMAIN][ - entry.entry_id - ] + coordinator: ReCollectWasteDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] return async_redact_data( { diff --git a/homeassistant/components/recollect_waste/entity.py b/homeassistant/components/recollect_waste/entity.py index a300e527fd2be7..891f1706f77b15 100644 --- a/homeassistant/components/recollect_waste/entity.py +++ b/homeassistant/components/recollect_waste/entity.py @@ -1,25 +1,21 @@ """Define a base ReCollect Waste entity.""" -from aiorecollect.client import PickupEvent - from homeassistant.config_entries import ConfigEntry 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 CONF_PLACE_ID, CONF_SERVICE_ID, DOMAIN +from .coordinator import ReCollectWasteDataUpdateCoordinator -class ReCollectWasteEntity(CoordinatorEntity[DataUpdateCoordinator[list[PickupEvent]]]): +class ReCollectWasteEntity(CoordinatorEntity[ReCollectWasteDataUpdateCoordinator]): """Define a base ReCollect Waste entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: DataUpdateCoordinator[list[PickupEvent]], + coordinator: ReCollectWasteDataUpdateCoordinator, entry: ConfigEntry, ) -> None: """Initialize the sensor.""" diff --git a/homeassistant/components/recollect_waste/sensor.py b/homeassistant/components/recollect_waste/sensor.py index 69b1772b9faf7f..97d6c1413e13f5 100644 --- a/homeassistant/components/recollect_waste/sensor.py +++ b/homeassistant/components/recollect_waste/sensor.py @@ -4,8 +4,6 @@ from datetime import date -from aiorecollect.client import PickupEvent - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -14,9 +12,9 @@ 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 DataUpdateCoordinator from .const import DOMAIN, LOGGER +from .coordinator import ReCollectWasteDataUpdateCoordinator from .entity import ReCollectWasteEntity from .util import async_get_pickup_type_names @@ -44,9 +42,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ReCollect Waste sensors based on a config entry.""" - coordinator: DataUpdateCoordinator[list[PickupEvent]] = hass.data[DOMAIN][ - entry.entry_id - ] + coordinator: ReCollectWasteDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( ReCollectWasteSensor(coordinator, entry, description) @@ -66,7 +62,7 @@ class ReCollectWasteSensor(ReCollectWasteEntity, SensorEntity): def __init__( self, - coordinator: DataUpdateCoordinator[list[PickupEvent]], + coordinator: ReCollectWasteDataUpdateCoordinator, entry: ConfigEntry, description: SensorEntityDescription, ) -> None: diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index a1a9ac1bc64091..f4b37d36742fed 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -8,7 +8,7 @@ "quality_scale": "internal", "requirements": [ "SQLAlchemy==2.0.41", - "fnv-hash-fast==1.6.0", + "fnv-hash-fast==2.0.0", "psutil-home-assistant==0.0.1" ] } diff --git a/homeassistant/components/recorder/strings.json b/homeassistant/components/recorder/strings.json index d830c5bd304f41..35286836318635 100644 --- a/homeassistant/components/recorder/strings.json +++ b/homeassistant/components/recorder/strings.json @@ -5,7 +5,7 @@ "title": "Database backup failed due to lack of resources" }, "maria_db_range_index_regression": { - "description": "Older versions of MariaDB suffer from a significant performance regression when retrieving history data or purging the database. Update to MariaDB version {min_version} or later and restart Home Assistant. If you are using the MariaDB core add-on, make sure to update it to the latest version.", + "description": "Older versions of MariaDB suffer from a significant performance regression when retrieving history data or purging the database. Update to MariaDB version {min_version} or later and restart Home Assistant. If you are using the MariaDB Core app, make sure to update it to the latest version.", "title": "Update MariaDB to {min_version} or later resolve a significant performance issue" } }, diff --git a/homeassistant/components/recovery_mode/manifest.json b/homeassistant/components/recovery_mode/manifest.json index 1e46a4acde64ed..5837a648ecbf24 100644 --- a/homeassistant/components/recovery_mode/manifest.json +++ b/homeassistant/components/recovery_mode/manifest.json @@ -3,7 +3,7 @@ "name": "Recovery Mode", "codeowners": ["@home-assistant/core"], "config_flow": false, - "dependencies": ["frontend", "persistent_notification", "cloud"], + "dependencies": ["persistent_notification"], "documentation": "https://www.home-assistant.io/integrations/recovery_mode", "integration_type": "system", "quality_scale": "internal" diff --git a/homeassistant/components/recswitch/switch.py b/homeassistant/components/recswitch/switch.py index f5b566ce59d4e3..6a49a9a569943e 100644 --- a/homeassistant/components/recswitch/switch.py +++ b/homeassistant/components/recswitch/switch.py @@ -77,7 +77,7 @@ def name(self): return self.device_name @property - def is_on(self): + def is_on(self) -> bool: """Return true if switch is on.""" return self.gpio_state diff --git a/homeassistant/components/reddit/sensor.py b/homeassistant/components/reddit/sensor.py index 564cc6c3c06dab..963d7999c26b97 100644 --- a/homeassistant/components/reddit/sensor.py +++ b/homeassistant/components/reddit/sensor.py @@ -4,6 +4,7 @@ from datetime import timedelta import logging +from typing import Any import praw import voluptuous as vol @@ -98,8 +99,12 @@ def setup_platform( class RedditSensor(SensorEntity): """Representation of a Reddit sensor.""" + _attr_icon = "mdi:reddit" + def __init__(self, reddit, subreddit: str, limit: int, sort_by: str) -> None: """Initialize the Reddit sensor.""" + self._attr_name = f"reddit_{subreddit}" + self._attr_native_value = 0 self._reddit = reddit self._subreddit = subreddit self._limit = limit @@ -108,17 +113,7 @@ def __init__(self, reddit, subreddit: str, limit: int, sort_by: str) -> None: self._subreddit_data: list = [] @property - def name(self): - """Return the name of the sensor.""" - return f"reddit_{self._subreddit}" - - @property - def native_value(self): - """Return the state of the sensor.""" - return len(self._subreddit_data) - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return { ATTR_SUBREDDIT: self._subreddit, @@ -126,11 +121,6 @@ def extra_state_attributes(self): CONF_SORT_BY: self._sort_by, } - @property - def icon(self): - """Return the icon to use in the frontend.""" - return "mdi:reddit" - def update(self) -> None: """Update data from Reddit API.""" self._subreddit_data = [] @@ -155,3 +145,5 @@ def update(self) -> None: except praw.exceptions.PRAWException as err: _LOGGER.error("Reddit error %s", err) + + self._attr_native_value = len(self._subreddit_data) diff --git a/homeassistant/components/rehlko/binary_sensor.py b/homeassistant/components/rehlko/binary_sensor.py index a2c0d69473520d..f2353c0908887b 100644 --- a/homeassistant/components/rehlko/binary_sensor.py +++ b/homeassistant/components/rehlko/binary_sensor.py @@ -20,7 +20,7 @@ DEVICE_DATA_IS_CONNECTED, GENERATOR_DATA_DEVICE, ) -from .coordinator import RehlkoConfigEntry +from .coordinator import RehlkoConfigEntry, RehlkoUpdateCoordinator from .entity import RehlkoEntity # Coordinator is used to centralize the data updates @@ -73,19 +73,42 @@ async def async_setup_entry( """Set up the binary sensor platform.""" homes = config_entry.runtime_data.homes coordinators = config_entry.runtime_data.coordinators - async_add_entities( - RehlkoBinarySensorEntity( - coordinators[device_data[DEVICE_DATA_ID]], - device_data[DEVICE_DATA_ID], - device_data, - sensor_description, - document_key=sensor_description.document_key, - connectivity_key=sensor_description.connectivity_key, - ) - for home_data in homes - for device_data in home_data[DEVICE_DATA_DEVICES] - for sensor_description in BINARY_SENSORS - ) + entities: list[BinarySensorEntity] = [] + + for home_data in homes: + for device_data in home_data[DEVICE_DATA_DEVICES]: + device_id = device_data[DEVICE_DATA_ID] + coordinator = coordinators[device_id] + + # Add standard binary sensors + entities.extend( + RehlkoBinarySensorEntity( + coordinator, + device_id, + device_data, + sensor_description, + document_key=sensor_description.document_key, + connectivity_key=sensor_description.connectivity_key, + ) + for sensor_description in BINARY_SENSORS + ) + + # Add loadshed binary sensors if loadshed data is available + if (loadshed_data := coordinator.data.get("loadShed")) and ( + parameters := loadshed_data.get("parameters") + ): + entities.extend( + RehlkoLoadshedBinarySensorEntity( + coordinator, + device_id, + device_data, + parameter["definitionId"], + parameter["displayName"], + ) + for parameter in parameters + ) + + async_add_entities(entities) class RehlkoBinarySensorEntity(RehlkoEntity, BinarySensorEntity): @@ -106,3 +129,51 @@ def is_on(self) -> bool | None: self._rehlko_value, ) return None + + +class RehlkoLoadshedBinarySensorEntity(RehlkoEntity, BinarySensorEntity): + """Representation of a Loadshed Binary Sensor.""" + + def __init__( + self, + coordinator: RehlkoUpdateCoordinator, + device_id: int, + device_data: dict, + definition_id: int, + display_name: str, + ) -> None: + """Initialize the loadshed binary sensor.""" + # Create a synthetic entity description for this loadshed parameter + description = BinarySensorEntityDescription( + key=f"loadshed_{definition_id}", + translation_key="loadshed_parameter", + entity_registry_enabled_default=False, + ) + self._definition_id = definition_id + super().__init__( + coordinator, + device_id, + device_data, + description, + document_key=None, + connectivity_key=DEVICE_DATA_IS_CONNECTED, + ) + # Use translation placeholders for the dynamic display name + self._attr_translation_placeholders = {"display_name": display_name} + + @property + def is_on(self) -> bool | None: + """Return the state of the binary sensor.""" + if not (loadshed_data := self.coordinator.data.get("loadShed")) or not ( + parameters := loadshed_data.get("parameters") + ): + return None + + return next( + ( + parameter.get("value") + for parameter in parameters + if parameter["definitionId"] == self._definition_id + ), + None, + ) diff --git a/homeassistant/components/rehlko/icons.json b/homeassistant/components/rehlko/icons.json index b69e2e32d1b900..e28058e2ecdede 100644 --- a/homeassistant/components/rehlko/icons.json +++ b/homeassistant/components/rehlko/icons.json @@ -1,5 +1,10 @@ { "entity": { + "binary_sensor": { + "loadshed_parameter": { + "default": "mdi:transmission-tower-off" + } + }, "sensor": { "device_ip_address": { "default": "mdi:ip-network" diff --git a/homeassistant/components/rehlko/strings.json b/homeassistant/components/rehlko/strings.json index b5373a5a526518..e802d234c93ae9 100644 --- a/homeassistant/components/rehlko/strings.json +++ b/homeassistant/components/rehlko/strings.json @@ -35,6 +35,9 @@ "auto_run": { "name": "Auto run" }, + "loadshed_parameter": { + "name": "Load shed {display_name}" + }, "oil_pressure": { "name": "Oil pressure" } diff --git a/homeassistant/components/rejseplanen/sensor.py b/homeassistant/components/rejseplanen/sensor.py index 87e0947c78db9f..6265fffc7b6c30 100644 --- a/homeassistant/components/rejseplanen/sensor.py +++ b/homeassistant/components/rejseplanen/sensor.py @@ -10,6 +10,7 @@ from datetime import datetime, timedelta import logging from operator import itemgetter +from typing import Any import rjpl import voluptuous as vol @@ -124,7 +125,7 @@ def native_value(self): return self._state @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" if not self._times: return {ATTR_STOP_ID: self._stop_id} diff --git a/homeassistant/components/remote/icons.json b/homeassistant/components/remote/icons.json index 43a7f6ee7b659f..1560336d7c1a92 100644 --- a/homeassistant/components/remote/icons.json +++ b/homeassistant/components/remote/icons.json @@ -26,5 +26,13 @@ "turn_on": { "service": "mdi:remote" } + }, + "triggers": { + "turned_off": { + "trigger": "mdi:remote-off" + }, + "turned_on": { + "trigger": "mdi:remote" + } } } diff --git a/homeassistant/components/remote/strings.json b/homeassistant/components/remote/strings.json index 52aeeca7560149..e2f6af02673799 100644 --- a/homeassistant/components/remote/strings.json +++ b/homeassistant/components/remote/strings.json @@ -1,4 +1,8 @@ { + "common": { + "trigger_behavior_description": "The behavior of the targeted remotes to trigger on.", + "trigger_behavior_name": "Behavior" + }, "device_automation": { "action_type": { "toggle": "[%key:common::device_automation::action_type::toggle%]", @@ -27,6 +31,15 @@ } } }, + "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.", @@ -113,5 +126,27 @@ "name": "[%key:common::action::turn_on%]" } }, - "title": "Remote" + "title": "Remote", + "triggers": { + "turned_off": { + "description": "Triggers when one or more remotes turn off.", + "fields": { + "behavior": { + "description": "[%key:component::remote::common::trigger_behavior_description%]", + "name": "[%key:component::remote::common::trigger_behavior_name%]" + } + }, + "name": "Remote turned off" + }, + "turned_on": { + "description": "Triggers when one or more remotes turn on.", + "fields": { + "behavior": { + "description": "[%key:component::remote::common::trigger_behavior_description%]", + "name": "[%key:component::remote::common::trigger_behavior_name%]" + } + }, + "name": "Remote turned on" + } + } } diff --git a/homeassistant/components/remote/trigger.py b/homeassistant/components/remote/trigger.py new file mode 100644 index 00000000000000..92a946c5ab77b0 --- /dev/null +++ b/homeassistant/components/remote/trigger.py @@ -0,0 +1,17 @@ +"""Provides triggers for remotes.""" + +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger + +from . import DOMAIN + +TRIGGERS: dict[str, type[Trigger]] = { + "turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON), + "turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for remotes.""" + return TRIGGERS diff --git a/homeassistant/components/remote/triggers.yaml b/homeassistant/components/remote/triggers.yaml new file mode 100644 index 00000000000000..6dadeba1fd2a9a --- /dev/null +++ b/homeassistant/components/remote/triggers.yaml @@ -0,0 +1,18 @@ +.trigger_common: &trigger_common + target: + entity: + domain: remote + fields: + behavior: + required: true + default: any + selector: + select: + options: + - first + - last + - any + translation_key: trigger_behavior + +turned_off: *trigger_common +turned_on: *trigger_common diff --git a/homeassistant/components/remote_calendar/calendar.py b/homeassistant/components/remote_calendar/calendar.py index 86a49e6b0c6fa6..10e1bb44295b91 100644 --- a/homeassistant/components/remote_calendar/calendar.py +++ b/homeassistant/components/remote_calendar/calendar.py @@ -1,9 +1,10 @@ """Calendar platform for a Remote Calendar.""" -from datetime import datetime +from datetime import datetime, timedelta import logging from ical.event import Event +from ical.timeline import Timeline, materialize_timeline from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.core import HomeAssistant @@ -20,6 +21,14 @@ # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 +# Every coordinator update refresh, we materialize a timeline of upcoming +# events for determining state. This is done in the background to avoid blocking +# the event loop. When a state update happens we can scan for active events on +# the materialized timeline. These parameters control the maximum lookahead +# window and number of events we materialize from the calendar. +MAX_LOOKAHEAD_EVENTS = 20 +MAX_LOOKAHEAD_TIME = timedelta(days=365) + async def async_setup_entry( hass: HomeAssistant, @@ -48,12 +57,18 @@ def __init__( super().__init__(coordinator) self._attr_name = entry.data[CONF_CALENDAR_NAME] self._attr_unique_id = entry.entry_id - self._event: CalendarEvent | None = None + self._timeline: Timeline | None = None @property def event(self) -> CalendarEvent | None: """Return the next upcoming event.""" - return self._event + if self._timeline is None: + return None + now = dt_util.now() + events = self._timeline.active_after(now) + if event := next(events, None): + return _get_calendar_event(event) + return None async def async_get_events( self, hass: HomeAssistant, start_date: datetime, end_date: datetime @@ -79,14 +94,18 @@ async def async_update(self) -> None: """ await super().async_update() - def next_event() -> CalendarEvent | None: + def _get_timeline() -> Timeline | None: + """Return a materialized timeline with upcoming events.""" now = dt_util.now() - events = self.coordinator.data.timeline_tz(now.tzinfo).active_after(now) - if event := next(events, None): - return _get_calendar_event(event) - return None + timeline = self.coordinator.data.timeline_tz(now.tzinfo) + return materialize_timeline( + timeline, + start=now, + stop=now + MAX_LOOKAHEAD_TIME, + max_number_of_events=MAX_LOOKAHEAD_EVENTS, + ) - self._event = await self.hass.async_add_executor_job(next_event) + self._timeline = await self.hass.async_add_executor_job(_get_timeline) def _get_calendar_event(event: Event) -> CalendarEvent: diff --git a/homeassistant/components/remote_calendar/client.py b/homeassistant/components/remote_calendar/client.py index f0f243ca386383..927da8731d8a9a 100644 --- a/homeassistant/components/remote_calendar/client.py +++ b/homeassistant/components/remote_calendar/client.py @@ -1,12 +1,22 @@ -"""Specifies the parameter for the httpx download.""" +"""HTTP client for fetching remote calendar data.""" -from httpx import AsyncClient, Response, Timeout +from httpx import AsyncClient, Auth, BasicAuth, Response, Timeout -async def get_calendar(client: AsyncClient, url: str) -> Response: +async def get_calendar( + client: AsyncClient, + url: str, + username: str | None = None, + password: str | None = None, +) -> Response: """Make an HTTP GET request using Home Assistant's async HTTPX client with timeout.""" + auth: Auth | None = None + if username is not None and password is not None: + auth = BasicAuth(username, password) + return await client.get( url, + auth=auth, follow_redirects=True, timeout=Timeout(5, read=30, write=5, pool=5), ) diff --git a/homeassistant/components/remote_calendar/config_flow.py b/homeassistant/components/remote_calendar/config_flow.py index 0e23ecfc8d106a..77dbdd886da914 100644 --- a/homeassistant/components/remote_calendar/config_flow.py +++ b/homeassistant/components/remote_calendar/config_flow.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_URL, CONF_VERIFY_SSL +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.helpers.httpx_client import get_async_client from .client import get_calendar @@ -25,12 +25,24 @@ } ) +STEP_AUTH_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Remote Calendar.""" VERSION = 1 + def __init__(self) -> None: + """Initialize the config flow.""" + super().__init__() + self.data: dict[str, Any] = {} + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -39,8 +51,7 @@ async def async_step_user( return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA ) - errors: dict = {} - _LOGGER.debug("User input: %s", user_input) + errors: dict[str, str] = {} self._async_abort_entries_match( {CONF_CALENDAR_NAME: user_input[CONF_CALENDAR_NAME]} ) @@ -52,6 +63,11 @@ async def async_step_user( client = get_async_client(self.hass, verify_ssl=user_input[CONF_VERIFY_SSL]) try: res = await get_calendar(client, user_input[CONF_URL]) + if res.status_code == HTTPStatus.UNAUTHORIZED: + www_auth = res.headers.get("www-authenticate", "").lower() + if "basic" in www_auth: + self.data = user_input + return await self.async_step_auth() if res.status_code == HTTPStatus.FORBIDDEN: errors["base"] = "forbidden" return self.async_show_form( @@ -83,3 +99,60 @@ async def async_step_user( data_schema=STEP_USER_DATA_SCHEMA, errors=errors, ) + + async def async_step_auth( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the authentication step.""" + if user_input is None: + return self.async_show_form( + step_id="auth", + data_schema=STEP_AUTH_DATA_SCHEMA, + ) + + errors: dict[str, str] = {} + client = get_async_client(self.hass, verify_ssl=self.data[CONF_VERIFY_SSL]) + try: + res = await get_calendar( + client, + self.data[CONF_URL], + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + ) + if res.status_code == HTTPStatus.UNAUTHORIZED: + errors["base"] = "invalid_auth" + elif res.status_code == HTTPStatus.FORBIDDEN: + return self.async_abort(reason="forbidden") + else: + res.raise_for_status() + except TimeoutException as err: + errors["base"] = "timeout_connect" + _LOGGER.debug( + "A timeout error occurred: %s", str(err) or type(err).__name__ + ) + except (HTTPError, InvalidURL) as err: + errors["base"] = "cannot_connect" + _LOGGER.debug("An error occurred: %s", str(err) or type(err).__name__) + else: + if not errors: + try: + await parse_calendar(self.hass, res.text) + except InvalidIcsException: + return self.async_abort(reason="invalid_ics_file") + else: + return self.async_create_entry( + title=self.data[CONF_CALENDAR_NAME], + data={ + **self.data, + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + + return self.async_show_form( + step_id="auth", + data_schema=self.add_suggested_values_to_schema( + STEP_AUTH_DATA_SCHEMA, user_input + ), + errors=errors, + ) diff --git a/homeassistant/components/remote_calendar/coordinator.py b/homeassistant/components/remote_calendar/coordinator.py index 2d592c3cb9b456..a949b046f82220 100644 --- a/homeassistant/components/remote_calendar/coordinator.py +++ b/homeassistant/components/remote_calendar/coordinator.py @@ -7,7 +7,7 @@ from ical.calendar import Calendar from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_URL, CONF_VERIFY_SSL +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -46,11 +46,18 @@ def __init__( hass, verify_ssl=config_entry.data.get(CONF_VERIFY_SSL, True) ) self._url = config_entry.data[CONF_URL] + self._username: str | None = config_entry.data.get(CONF_USERNAME) + self._password: str | None = config_entry.data.get(CONF_PASSWORD) async def _async_update_data(self) -> Calendar: """Update data from the url.""" try: - res = await get_calendar(self._client, self._url) + res = await get_calendar( + self._client, + self._url, + username=self._username, + password=self._password, + ) res.raise_for_status() except TimeoutException as err: _LOGGER.debug("%s: %s", self._url, str(err) or type(err).__name__) diff --git a/homeassistant/components/remote_calendar/manifest.json b/homeassistant/components/remote_calendar/manifest.json index 9c8dbacb1b8d6b..62bc30664b885e 100644 --- a/homeassistant/components/remote_calendar/manifest.json +++ b/homeassistant/components/remote_calendar/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["ical"], "quality_scale": "silver", - "requirements": ["ical==12.1.3"] + "requirements": ["ical==13.2.2"] } diff --git a/homeassistant/components/remote_calendar/strings.json b/homeassistant/components/remote_calendar/strings.json index f34dd2e96e6b74..61faf1d44c1790 100644 --- a/homeassistant/components/remote_calendar/strings.json +++ b/homeassistant/components/remote_calendar/strings.json @@ -1,15 +1,29 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "forbidden": "[%key:component::remote_calendar::config::error::forbidden%]", + "invalid_ics_file": "[%key:component::remote_calendar::config::error::invalid_ics_file%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "forbidden": "The server understood the request but refuses to authorize it.", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_ics_file": "There was a problem reading the calendar information. See the error log for additional details.", "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]" }, "step": { + "auth": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "The password for HTTP Basic Authentication.", + "username": "The username for HTTP Basic Authentication." + }, + "description": "The calendar requires authentication." + }, "user": { "data": { "calendar_name": "Calendar name", diff --git a/homeassistant/components/renault/const.py b/homeassistant/components/renault/const.py index 1dffededf38ca3..446cca10905865 100644 --- a/homeassistant/components/renault/const.py +++ b/homeassistant/components/renault/const.py @@ -18,6 +18,7 @@ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.DEVICE_TRACKER, + Platform.NUMBER, Platform.SELECT, Platform.SENSOR, ] diff --git a/homeassistant/components/renault/icons.json b/homeassistant/components/renault/icons.json index 2302f67b693c83..f1767dcfbf619a 100644 --- a/homeassistant/components/renault/icons.json +++ b/homeassistant/components/renault/icons.json @@ -52,6 +52,13 @@ "charging_remaining_time": { "default": "mdi:timer" }, + "charging_settings_mode": { + "default": "mdi:calendar-remove", + "state": { + "delayed": "mdi:calendar-clock", + "scheduled": "mdi:calendar-month" + } + }, "fuel_autonomy": { "default": "mdi:gas-station" }, diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json index 23a3933cac93cd..8498001de7b2bb 100644 --- a/homeassistant/components/renault/manifest.json +++ b/homeassistant/components/renault/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["renault_api"], "quality_scale": "silver", - "requirements": ["renault-api==0.5.3"] + "requirements": ["renault-api==0.5.6"] } diff --git a/homeassistant/components/renault/number.py b/homeassistant/components/renault/number.py new file mode 100644 index 00000000000000..555bb9b9e72b9e --- /dev/null +++ b/homeassistant/components/renault/number.py @@ -0,0 +1,144 @@ +"""Support for Renault number entities.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any, cast + +from renault_api.kamereon.models import KamereonVehicleBatterySocData + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import RenaultConfigEntry +from .const import DOMAIN +from .entity import RenaultDataEntity, RenaultDataEntityDescription + +# Coordinator is used to centralize the data updates +# but renault servers are unreliable and it's safer to queue action calls +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class RenaultNumberEntityDescription( + NumberEntityDescription, RenaultDataEntityDescription +): + """Class describing Renault number entities.""" + + data_key: str + update_fn: Callable[[RenaultNumberEntity, float], Coroutine[Any, Any, None]] + + +async def _set_charge_limit_min(entity: RenaultNumberEntity, 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: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="battery_soc_unavailable", + ) + await _set_charge_limits(entity, min_soc=round(value), target_soc=target_soc) + + +async def _set_charge_limit_target(entity: RenaultNumberEntity, 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: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="battery_soc_unavailable", + ) + await _set_charge_limits(entity, min_soc=min_soc, target_soc=round(value)) + + +async def _set_charge_limits( + entity: RenaultNumberEntity, min_soc: int, target_soc: int +) -> None: + """Set the minimum and target SOC. + + Optimistically update local coordinator data so the new + limits are reflected immediately without a remote refresh, + as Renault servers may still cache old values. + """ + await entity.vehicle.set_battery_soc(min_soc=min_soc, target_soc=target_soc) + + entity.coordinator.data.socMin = min_soc + entity.coordinator.data.socTarget = target_soc + entity.coordinator.async_set_updated_data(entity.coordinator.data) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: RenaultConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Renault entities from config entry.""" + entities: list[RenaultNumberEntity] = [ + RenaultNumberEntity(vehicle, description) + for vehicle in config_entry.runtime_data.vehicles.values() + for description in NUMBER_TYPES + if description.coordinator in vehicle.coordinators + ] + async_add_entities(entities) + + +class RenaultNumberEntity( + RenaultDataEntity[KamereonVehicleBatterySocData], NumberEntity +): + """Mixin for number specific attributes.""" + + entity_description: RenaultNumberEntityDescription + + @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)) + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + await self.entity_description.update_fn(self, value) + + +NUMBER_TYPES: tuple[RenaultNumberEntityDescription, ...] = ( + RenaultNumberEntityDescription( + key="charge_limit_min", + coordinator="battery_soc", + data_key="socMin", + update_fn=_set_charge_limit_min, + device_class=NumberDeviceClass.BATTERY, + native_min_value=15, + native_max_value=45, + native_step=5, + native_unit_of_measurement=PERCENTAGE, + mode=NumberMode.SLIDER, + translation_key="charge_limit_min", + ), + RenaultNumberEntityDescription( + key="charge_limit_target", + coordinator="battery_soc", + data_key="socTarget", + update_fn=_set_charge_limit_target, + device_class=NumberDeviceClass.BATTERY, + native_min_value=55, + native_max_value=100, + native_step=5, + native_unit_of_measurement=PERCENTAGE, + mode=NumberMode.SLIDER, + translation_key="charge_limit_target", + ), +) diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index c7eddeef881b2f..e2acb1bc07d397 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any, Concatenate, cast from renault_api.exceptions import RenaultException -from renault_api.kamereon import models, schemas +from renault_api.kamereon import models from renault_api.renault_vehicle import RenaultVehicle from homeassistant.core import HomeAssistant @@ -174,6 +174,13 @@ async def set_charge_stop(self) -> models.KamereonVehicleChargingStartActionData """Stop vehicle charge.""" return await self._vehicle.set_charge_stop() + @with_error_wrapping + async def set_battery_soc( + self, min_soc: int, target_soc: int + ) -> models.KamereonVehicleBatterySocActionData: + """Set vehicle battery SoC levels.""" + return await self._vehicle.set_battery_soc(min=min_soc, target=target_soc) + @with_error_wrapping async def set_ac_stop(self) -> models.KamereonVehicleHvacStartActionData: """Stop vehicle ac.""" @@ -201,18 +208,7 @@ async def set_hvac_schedules( @with_error_wrapping async def get_charging_settings(self) -> models.KamereonVehicleChargingSettingsData: """Get vehicle charging settings.""" - full_endpoint = await self._vehicle.get_full_endpoint("charging-settings") - response = await self._vehicle.http_get(full_endpoint) - response_data = cast( - models.KamereonVehicleDataResponse, - schemas.KamereonVehicleDataResponseSchema.load(response.raw_data), - ) - return cast( - models.KamereonVehicleChargingSettingsData, - response_data.get_attributes( - schemas.KamereonVehicleChargingSettingsDataSchema - ), - ) + return await self._vehicle.get_charging_settings() @with_error_wrapping async def set_charge_schedules( @@ -260,6 +256,12 @@ async def flash_lights(self) -> None: requires_electricity=True, update_method=lambda x: x.get_charge_mode, ), + RenaultCoordinatorDescription( + endpoint="charging-settings", + key="charging_settings", + requires_electricity=True, + update_method=lambda x: x.get_charging_settings, + ), RenaultCoordinatorDescription( endpoint="lock-status", key="lock_status", @@ -275,4 +277,10 @@ async def flash_lights(self) -> None: key="pressure", update_method=lambda x: x.get_tyre_pressure, ), + RenaultCoordinatorDescription( + endpoint="soc-levels", + key="battery_soc", + requires_electricity=True, + update_method=lambda x: x.get_battery_soc, + ), ) diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index e3eefde1aa748e..66e1a4be93b81f 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -9,6 +9,7 @@ from renault_api.kamereon.models import ( KamereonVehicleBatteryStatusData, + KamereonVehicleChargingSettingsData, KamereonVehicleCockpitData, KamereonVehicleHvacStatusData, KamereonVehicleLocationData, @@ -128,6 +129,13 @@ def _get_utc_value(entity: RenaultSensor[T]) -> datetime: return as_utc(original_dt) +def _get_charging_settings_mode_formatted(entity: RenaultSensor[T]) -> str | None: + """Return the charging_settings mode of this entity.""" + data = cast(KamereonVehicleChargingSettingsData, entity.coordinator.data) + charging_mode = data.mode if data else None + return charging_mode.lower() if charging_mode else None + + SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( RenaultSensorEntityDescription( key="battery_level", @@ -339,6 +347,20 @@ def _get_utc_value(entity: RenaultSensor[T]) -> datetime: entity_registry_enabled_default=False, translation_key="res_state_code", ), + RenaultSensorEntityDescription( + 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", + "delayed", + "scheduled", + ], + value_lambda=_get_charging_settings_mode_formatted, + ), RenaultSensorEntityDescription( key="front_left_pressure", coordinator="pressure", diff --git a/homeassistant/components/renault/services.py b/homeassistant/components/renault/services.py index df85ad57f668a2..03531924533c6a 100644 --- a/homeassistant/components/renault/services.py +++ b/homeassistant/components/renault/services.py @@ -92,17 +92,6 @@ } ) -SERVICE_AC_CANCEL = "ac_cancel" -SERVICE_AC_START = "ac_start" -SERVICE_CHARGE_SET_SCHEDULES = "charge_set_schedules" -SERVICE_AC_SET_SCHEDULES = "ac_set_schedules" -SERVICES = [ - SERVICE_AC_CANCEL, - SERVICE_AC_START, - SERVICE_CHARGE_SET_SCHEDULES, - SERVICE_AC_SET_SCHEDULES, -] - async def ac_cancel(service_call: ServiceCall) -> None: """Cancel A/C.""" @@ -197,25 +186,25 @@ def async_setup_services(hass: HomeAssistant) -> None: hass.services.async_register( DOMAIN, - SERVICE_AC_CANCEL, + "ac_cancel", ac_cancel, schema=SERVICE_VEHICLE_SCHEMA, ) hass.services.async_register( DOMAIN, - SERVICE_AC_START, + "ac_start", ac_start, schema=SERVICE_AC_START_SCHEMA, ) hass.services.async_register( DOMAIN, - SERVICE_CHARGE_SET_SCHEDULES, + "charge_set_schedules", charge_set_schedules, schema=SERVICE_CHARGE_SET_SCHEDULES_SCHEMA, ) hass.services.async_register( DOMAIN, - SERVICE_AC_SET_SCHEDULES, + "ac_set_schedules", ac_set_schedules, schema=SERVICE_AC_SET_SCHEDULES_SCHEMA, ) diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json index 7bccfe641c0127..a58575f68a3259 100644 --- a/homeassistant/components/renault/strings.json +++ b/homeassistant/components/renault/strings.json @@ -94,6 +94,14 @@ "name": "[%key:common::config_flow::data::location%]" } }, + "number": { + "charge_limit_min": { + "name": "Minimum charge level" + }, + "charge_limit_target": { + "name": "Target charge level" + } + }, "select": { "charge_mode": { "name": "Charge mode", @@ -140,6 +148,14 @@ "charging_remaining_time": { "name": "Charging remaining time" }, + "charging_settings_mode": { + "name": "Charging mode", + "state": { + "always": "Always", + "delayed": "Delayed", + "scheduled": "Scheduled" + } + }, "front_left_pressure": { "name": "Front left tyre pressure" }, @@ -191,6 +207,9 @@ } }, "exceptions": { + "battery_soc_unavailable": { + "message": "Battery state of charge data is currently unavailable" + }, "invalid_device_id": { "message": "No device with ID {device_id} was found" }, diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 5fbe1ba39512e1..a2ea96459b2253 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio from collections.abc import Callable from datetime import UTC, datetime, timedelta import logging @@ -11,13 +10,8 @@ from typing import Any from reolink_aio.api import RETRY_ATTEMPTS -from reolink_aio.exceptions import ( - CredentialsInvalidError, - LoginPrivacyModeError, - ReolinkError, -) +from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError -from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -29,7 +23,6 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL, @@ -40,6 +33,7 @@ CONF_USE_HTTPS, DOMAIN, ) +from .coordinator import ReolinkDeviceCoordinator, ReolinkFirmwareCoordinator from .exceptions import PasswordIncompatible, ReolinkException, UserNotAdmin from .host import ReolinkHost from .services import async_setup_services @@ -60,10 +54,7 @@ Platform.SWITCH, Platform.UPDATE, ] -DEVICE_UPDATE_INTERVAL_MIN = timedelta(seconds=60) -DEVICE_UPDATE_INTERVAL_PER_CAM = timedelta(seconds=10) FIRMWARE_UPDATE_INTERVAL = timedelta(hours=24) -NUM_CRED_ERRORS = 3 CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -141,81 +132,21 @@ async def async_setup_entry( hass.config_entries.async_update_entry(config_entry, data=data) min_timeout = host.api.timeout * (RETRY_ATTEMPTS + 2) - update_timeout = max(min_timeout, min_timeout * host.api.num_cameras / 10) - - async def async_device_config_update() -> None: - """Update the host state cache and renew the ONVIF-subscription.""" - async with asyncio.timeout(update_timeout): - try: - await host.update_states() - except CredentialsInvalidError as err: - host.credential_errors += 1 - if host.credential_errors >= NUM_CRED_ERRORS: - await host.stop() - raise ConfigEntryAuthFailed(err) from err - raise UpdateFailed(str(err)) from err - except LoginPrivacyModeError: - pass # HTTP API is shutdown when privacy mode is active - except ReolinkError as err: - host.credential_errors = 0 - raise UpdateFailed(str(err)) from err - - host.credential_errors = 0 - - async with asyncio.timeout(min_timeout): - await host.renew() - - if host.api.new_devices and config_entry.state == ConfigEntryState.LOADED: - # Their are new cameras/chimes connected, reload to add them. - _LOGGER.debug( - "Reloading Reolink %s to add new device (capabilities)", - host.api.nvr_name, - ) - hass.async_create_task( - hass.config_entries.async_reload(config_entry.entry_id) - ) - - async def async_check_firmware_update() -> None: - """Check for firmware updates.""" - async with asyncio.timeout(min_timeout): - try: - await host.api.check_new_firmware(host.firmware_ch_list) - except ReolinkError as err: - if host.starting: - _LOGGER.debug( - "Error checking Reolink firmware update at startup " - "from %s, possibly internet access is blocked", - host.api.nvr_name, - ) - return - - raise UpdateFailed( - f"Error checking Reolink firmware update from {host.api.nvr_name}, " - "if the camera is blocked from accessing the internet, " - "disable the update entity" - ) from err - finally: - host.starting = False - device_coordinator = DataUpdateCoordinator( + device_coordinator = ReolinkDeviceCoordinator( hass, - _LOGGER, - config_entry=config_entry, - name=f"reolink.{host.api.nvr_name}", - update_method=async_device_config_update, - update_interval=max( - DEVICE_UPDATE_INTERVAL_MIN, - DEVICE_UPDATE_INTERVAL_PER_CAM * host.api.num_cameras, - ), + config_entry, + host, + min_timeout=min_timeout, ) - firmware_coordinator = DataUpdateCoordinator( + + firmware_coordinator = ReolinkFirmwareCoordinator( hass, - _LOGGER, - config_entry=config_entry, - name=f"reolink.{host.api.nvr_name}.firmware", - update_method=async_check_firmware_update, - update_interval=None, # Do not fetch data automatically, resume 24h schedule + config_entry, + host, + min_timeout=min_timeout, ) + device_coordinator.firmware_coordinator = firmware_coordinator async def first_firmware_check(*args: Any) -> None: """Start first firmware check delayed to continue 24h schedule.""" @@ -283,7 +214,7 @@ async def first_firmware_check(*args: Any) -> None: async def register_callbacks( host: ReolinkHost, - device_coordinator: DataUpdateCoordinator[None], + device_coordinator: ReolinkDeviceCoordinator, hass: HomeAssistant, ) -> None: """Register update callbacks.""" @@ -543,7 +474,20 @@ def migrate_entity_ids( entity.unique_id, new_id, ) - entity_reg.async_update_entity(entity.entity_id, new_unique_id=new_id) + existing_entity = entity_reg.async_get_entity_id( + entity.domain, entity.platform, new_id + ) + if existing_entity is None: + entity_reg.async_update_entity(entity.entity_id, new_unique_id=new_id) + else: + _LOGGER.warning( + "Reolink entity with unique_id %s already exists, " + "removing entity with unique_id %s", + new_id, + entity.unique_id, + ) + entity_reg.async_remove(entity.entity_id) + continue if entity.device_id in ch_device_ids: ch = ch_device_ids[entity.device_id] @@ -573,7 +517,7 @@ def migrate_entity_ids( else: _LOGGER.warning( "Reolink entity with unique_id %s already exists, " - "removing device with unique_id %s", + "removing entity with unique_id %s", new_id, entity.unique_id, ) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 2ac51792c3fb1d..80d403c6e38cc6 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -159,6 +159,15 @@ async def async_step_dhcp( """Handle discovery via dhcp.""" mac_address = format_mac(discovery_info.macaddress) existing_entry = await self.async_set_unique_id(mac_address) + if existing_entry and CONF_HOST not in existing_entry.data: + _LOGGER.debug( + "Reolink DHCP discovered device with MAC '%s' and IP '%s', " + "but existing config entry does not have host, ignoring", + mac_address, + discovery_info.ip, + ) + raise AbortFlow("already_configured") + if ( existing_entry and CONF_PASSWORD in existing_entry.data diff --git a/homeassistant/components/reolink/coordinator.py b/homeassistant/components/reolink/coordinator.py new file mode 100644 index 00000000000000..094039d57a37f8 --- /dev/null +++ b/homeassistant/components/reolink/coordinator.py @@ -0,0 +1,178 @@ +"""Data update coordinators for Reolink.""" + +from __future__ import annotations + +import asyncio +from datetime import timedelta +import logging + +from reolink_aio.exceptions import ( + CredentialsInvalidError, + LoginPrivacyModeError, + ReolinkError, +) + +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .host import ReolinkHost + +_LOGGER = logging.getLogger(__name__) + +NUM_CRED_ERRORS = 3 + +DEVICE_UPDATE_INTERVAL_MIN = timedelta(seconds=60) +DEVICE_UPDATE_INTERVAL_PER_CAM = timedelta(seconds=10) + + +class ReolinkCoordinator(DataUpdateCoordinator[None]): + """Coordinator for Reolink.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + host: ReolinkHost, + name: str, + *, + min_timeout: float, + update_interval: timedelta | None, + ) -> None: + """Initialize the device coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=name, + update_interval=update_interval, + ) + self._host = host + self._min_timeout = min_timeout + + +class ReolinkDeviceCoordinator(ReolinkCoordinator): + """Coordinator for Reolink device state updates.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + host: ReolinkHost, + *, + min_timeout: float, + ) -> None: + """Initialize the device coordinator.""" + super().__init__( + hass, + config_entry, + host, + f"reolink.{host.api.nvr_name}", + min_timeout=min_timeout, + update_interval=max( + DEVICE_UPDATE_INTERVAL_MIN, + DEVICE_UPDATE_INTERVAL_PER_CAM * host.api.num_cameras, + ), + ) + self._update_timeout = max(min_timeout, min_timeout * host.api.num_cameras / 10) + self._last_known_firmware: dict[int | None, str | None] = {} + self.firmware_coordinator: ReolinkFirmwareCoordinator | None = None + + async def _async_update_data(self) -> None: + """Update the host state cache and renew the ONVIF-subscription.""" + async with asyncio.timeout(self._update_timeout): + try: + await self._host.update_states() + except CredentialsInvalidError as err: + self._host.credential_errors += 1 + if self._host.credential_errors >= NUM_CRED_ERRORS: + await self._host.stop() + raise ConfigEntryAuthFailed(err) from err + raise UpdateFailed(str(err)) from err + except LoginPrivacyModeError: + pass # HTTP API is shutdown when privacy mode is active + except ReolinkError as err: + self._host.credential_errors = 0 + raise UpdateFailed(str(err)) from err + + self._host.credential_errors = 0 + + # Check for firmware version changes (external update detection) + firmware_changed = False + for ch in (*self._host.api.channels, None): + new_version = self._host.api.camera_sw_version(ch) + old_version = self._last_known_firmware.get(ch) + if ( + old_version is not None + and new_version is not None + and new_version != old_version + ): + firmware_changed = True + self._last_known_firmware[ch] = new_version + + # Notify firmware coordinator if firmware changed externally + if firmware_changed and self.firmware_coordinator is not None: + self.firmware_coordinator.async_set_updated_data(None) + + async with asyncio.timeout(self._min_timeout): + await self._host.renew() + + if ( + self._host.api.new_devices + and self.config_entry.state == ConfigEntryState.LOADED + ): + # There are new cameras/chimes connected, reload to add them. + _LOGGER.debug( + "Reloading Reolink %s to add new device (capabilities)", + self._host.api.nvr_name, + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.config_entry.entry_id) + ) + + +class ReolinkFirmwareCoordinator(ReolinkCoordinator): + """Coordinator for Reolink firmware update checks.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + host: ReolinkHost, + *, + min_timeout: float, + ) -> None: + """Initialize the firmware coordinator.""" + super().__init__( + hass, + config_entry, + host, + f"reolink.{host.api.nvr_name}.firmware", + min_timeout=min_timeout, + update_interval=None, # Do not fetch data automatically, resume 24h schedule + ) + + async def _async_update_data(self) -> None: + """Check for firmware updates.""" + async with asyncio.timeout(self._min_timeout): + try: + await self._host.api.check_new_firmware(self._host.firmware_ch_list) + except ReolinkError as err: + if self._host.starting: + _LOGGER.debug( + "Error checking Reolink firmware update at startup " + "from %s, possibly internet access is blocked", + self._host.api.nvr_name, + ) + return + + raise UpdateFailed( + f"Error checking Reolink firmware update from {self._host.api.nvr_name}, " + "if the camera is blocked from accessing the internet, " + "disable the update entity" + ) from err + finally: + self._host.starting = False diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index c180e5f77b2601..6cdef5e4c32793 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -10,13 +10,11 @@ from homeassistant.core import callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import ReolinkData from .const import DOMAIN +from .coordinator import ReolinkCoordinator +from .util import ReolinkData @dataclass(frozen=True, kw_only=True) @@ -49,7 +47,7 @@ class ReolinkChimeEntityDescription(ReolinkEntityDescription): supported: Callable[[Chime], bool] = lambda chime: True -class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None]]): +class ReolinkHostCoordinatorEntity(CoordinatorEntity[ReolinkCoordinator]): """Parent class for entities that control the Reolink NVR itself, without a channel. A camera connected directly to HomeAssistant without using a NVR is in the reolink API @@ -62,7 +60,7 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] def __init__( self, reolink_data: ReolinkData, - coordinator: DataUpdateCoordinator[None] | None = None, + coordinator: ReolinkCoordinator | None = None, ) -> None: """Initialize ReolinkHostCoordinatorEntity.""" if coordinator is None: @@ -161,7 +159,7 @@ def __init__( self, reolink_data: ReolinkData, channel: int, - coordinator: DataUpdateCoordinator[None] | None = None, + coordinator: ReolinkCoordinator | None = None, ) -> None: """Initialize ReolinkChannelCoordinatorEntity for a hardware camera connected to a channel of the NVR.""" super().__init__(reolink_data, coordinator) @@ -250,7 +248,7 @@ def __init__( self, reolink_data: ReolinkData, chime: Chime, - coordinator: DataUpdateCoordinator[None] | None = None, + coordinator: ReolinkCoordinator | None = None, ) -> None: """Initialize ReolinkHostChimeCoordinatorEntity for a chime.""" super().__init__(reolink_data, coordinator) @@ -287,7 +285,7 @@ def __init__( self, reolink_data: ReolinkData, chime: Chime, - coordinator: DataUpdateCoordinator[None] | None = None, + coordinator: ReolinkCoordinator | None = None, ) -> None: """Initialize ReolinkChimeCoordinatorEntity for a chime.""" assert chime.channel is not None diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 02b6b4b754e50c..75976ff4ec5a12 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -20,5 +20,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.19.0"] + "requirements": ["reolink-aio==0.19.1"] } diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index 7b5bb9077d2b7c..7dfdd56f771e77 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -18,13 +18,14 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DEVICE_UPDATE_INTERVAL_MIN, DEVICE_UPDATE_INTERVAL_PER_CAM from .const import DOMAIN +from .coordinator import ( + DEVICE_UPDATE_INTERVAL_MIN, + DEVICE_UPDATE_INTERVAL_PER_CAM, + ReolinkCoordinator, +) from .entity import ( ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription, @@ -94,9 +95,7 @@ async def async_setup_entry( async_add_entities(entities) -class ReolinkUpdateBaseEntity( - CoordinatorEntity[DataUpdateCoordinator[None]], UpdateEntity -): +class ReolinkUpdateBaseEntity(CoordinatorEntity[ReolinkCoordinator], UpdateEntity): """Base update entity class for Reolink.""" _attr_release_url = "https://reolink.com/download-center/" @@ -105,7 +104,7 @@ def __init__( self, reolink_data: ReolinkData, channel: int | None, - coordinator: DataUpdateCoordinator[None], + coordinator: ReolinkCoordinator, ) -> None: """Initialize Reolink update entity.""" CoordinatorEntity.__init__(self, coordinator) diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py index a80e9f8962cb1d..e633cbac64f030 100644 --- a/homeassistant/components/reolink/util.py +++ b/homeassistant/components/reolink/util.py @@ -28,11 +28,11 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.storage import Store from homeassistant.helpers.translation import async_get_exception_message -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN if TYPE_CHECKING: + from .coordinator import ReolinkDeviceCoordinator, ReolinkFirmwareCoordinator from .host import ReolinkHost STORAGE_VERSION = 1 @@ -45,8 +45,8 @@ class ReolinkData: """Data for the Reolink integration.""" host: ReolinkHost - device_coordinator: DataUpdateCoordinator[None] - firmware_coordinator: DataUpdateCoordinator[None] + device_coordinator: ReolinkDeviceCoordinator + firmware_coordinator: ReolinkFirmwareCoordinator def is_connected(hass: HomeAssistant, config_entry: config_entries.ConfigEntry) -> bool: diff --git a/homeassistant/components/repetier/sensor.py b/homeassistant/components/repetier/sensor.py index 3903ab8adfb3c2..4cfa0799960815 100644 --- a/homeassistant/components/repetier/sensor.py +++ b/homeassistant/components/repetier/sensor.py @@ -75,18 +75,13 @@ def __init__( """Init new sensor.""" self.entity_description = description self._api = api - self._attributes: dict = {} + self._attr_extra_state_attributes = {} self._temp_id = temp_id self._printer_id = printer_id self._attr_name = name self._attr_available = False - @property - def extra_state_attributes(self): - """Return sensor attributes.""" - return self._attributes - @callback def update_callback(self): """Get new data and update state.""" @@ -115,7 +110,7 @@ def update(self) -> None: return state = data.pop("state") _LOGGER.debug("Printer %s State %s", self.name, state) - self._attributes.update(data) + self._attr_extra_state_attributes.update(data) self._attr_native_value = state @@ -136,7 +131,7 @@ def update(self) -> None: state = data.pop("state") temp_set = data["temp_set"] _LOGGER.debug("Printer %s Setpoint: %s, Temp: %s", self.name, temp_set, state) - self._attributes.update(data) + self._attr_extra_state_attributes.update(data) self._attr_native_value = state diff --git a/homeassistant/components/rflink/binary_sensor.py b/homeassistant/components/rflink/binary_sensor.py index 43a7c03c67b209..713dc02d6b8575 100644 --- a/homeassistant/components/rflink/binary_sensor.py +++ b/homeassistant/components/rflink/binary_sensor.py @@ -125,6 +125,6 @@ def off_delay_listener(now): ) @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" return self._state diff --git a/homeassistant/components/rflink/entity.py b/homeassistant/components/rflink/entity.py index 0caec4ea2c384a..fe9c5e6e4f2ed2 100644 --- a/homeassistant/components/rflink/entity.py +++ b/homeassistant/components/rflink/entity.py @@ -37,7 +37,6 @@ class RflinkDevice(Entity): """ _state: bool | None = None - _available = True _attr_should_poll = False def __init__( @@ -58,9 +57,9 @@ def __init__( self._device_id = device_id self._attr_unique_id = device_id if name: - self._name = name + self._attr_name = name else: - self._name = device_id + self._attr_name = device_id self._aliases = aliases self._group = group @@ -93,12 +92,7 @@ def _handle_event(self, event): raise NotImplementedError @property - def name(self): - """Return a name for the device.""" - return self._name - - @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if device is on.""" if self.assumed_state: return False @@ -109,15 +103,10 @@ def assumed_state(self) -> bool: """Assume device state until first device event sets state.""" return self._state is None - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - @callback def _availability_callback(self, availability): """Update availability state.""" - self._available = availability + self._attr_available = availability self.async_write_ha_state() async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/rflink/light.py b/homeassistant/components/rflink/light.py index 7eb53433d881f1..24bbf06c049676 100644 --- a/homeassistant/components/rflink/light.py +++ b/homeassistant/components/rflink/light.py @@ -226,7 +226,7 @@ def _handle_event(self, event): self._state = True @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" return self._brightness diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index 13e692bbd46c0e..ef01cf217439f3 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -31,5 +31,5 @@ "iot_class": "cloud_polling", "loggers": ["ring_doorbell"], "quality_scale": "bronze", - "requirements": ["ring-doorbell==0.9.13"] + "requirements": ["ring-doorbell==0.9.14"] } diff --git a/homeassistant/components/rmvtransport/sensor.py b/homeassistant/components/rmvtransport/sensor.py index 114df787053992..b85a731bac0d3f 100644 --- a/homeassistant/components/rmvtransport/sensor.py +++ b/homeassistant/components/rmvtransport/sensor.py @@ -5,6 +5,7 @@ import asyncio from datetime import timedelta import logging +from typing import Any from RMVtransport import RMVtransport from RMVtransport.rmvtransport import ( @@ -121,6 +122,7 @@ class RMVDepartureSensor(SensorEntity): """Implementation of an RMV departure sensor.""" _attr_attribution = ATTRIBUTION + _attr_native_unit_of_measurement = UnitOfTime.MINUTES def __init__( self, @@ -136,7 +138,7 @@ def __init__( ): """Initialize the sensor.""" self._station = station - self._name = name + self._attr_name = name self._state = None self.data = RMVDepartureData( station, @@ -148,12 +150,7 @@ def __init__( max_journeys, timeout, ) - self._icon = ICONS[None] - - @property - def name(self): - """Return the name of the sensor.""" - return self._name + self._attr_icon = ICONS[None] @property def available(self) -> bool: @@ -166,7 +163,7 @@ def native_value(self): return self._state @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" try: return { @@ -180,32 +177,22 @@ def extra_state_attributes(self): except IndexError: return {} - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon - - @property - def native_unit_of_measurement(self): - """Return the unit this state is expressed in.""" - return UnitOfTime.MINUTES - async def async_update(self) -> None: """Get the latest data and update the state.""" await self.data.async_update() - if self._name == DEFAULT_NAME: - self._name = self.data.station + if self._attr_name == DEFAULT_NAME: + self._attr_name = self.data.station self._station = self.data.station if not self.data.departures: self._state = None - self._icon = ICONS[None] + self._attr_icon = ICONS[None] return self._state = self.data.departures[0].get("minutes") - self._icon = ICONS[self.data.departures[0].get("product")] + self._attr_icon = ICONS[self.data.departures[0].get("product")] class RMVDepartureData: diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index b293620424d746..aa468570b0481b 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -39,6 +39,7 @@ ) from .coordinator import ( RoborockB01Q7UpdateCoordinator, + RoborockB01Q10UpdateCoordinator, RoborockConfigEntry, RoborockCoordinators, RoborockDataUpdateCoordinator, @@ -47,6 +48,7 @@ RoborockWashingMachineUpdateCoordinator, RoborockWetDryVacUpdateCoordinator, ) +from .models import get_device_info from .roborock_storage import CacheStore, async_cleanup_map_storage from .services import async_setup_services @@ -130,8 +132,22 @@ async def shutdown_roborock(_: Event | None = None) -> None: devices = await device_manager.get_devices() _LOGGER.debug("Device manager found %d devices", len(devices)) + # Register all discovered devices in the device registry so we can + # check the disabled state before creating coordinators. + device_registry = dr.async_get(hass) + for device in devices: + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + **get_device_info(device), + ) + + enabled_devices = [ + device for device in devices if not _is_device_disabled(device_registry, device) + ] + _LOGGER.debug("%d of %d devices are enabled", len(enabled_devices), len(devices)) + coordinators = await asyncio.gather( - *build_setup_functions(hass, entry, devices, user_data), + *build_setup_functions(hass, entry, enabled_devices, user_data), return_exceptions=True, ) v1_coords = [ @@ -144,18 +160,28 @@ async def shutdown_roborock(_: Event | None = None) -> None: for coord in coordinators if isinstance(coord, RoborockDataUpdateCoordinatorA01) ] - b01_coords = [ + b01_q7_coords = [ + coord + for coord in coordinators + if isinstance(coord, RoborockB01Q7UpdateCoordinator) + ] + b01_q10_coords = [ coord for coord in coordinators - if isinstance(coord, RoborockDataUpdateCoordinatorB01) + if isinstance(coord, RoborockB01Q10UpdateCoordinator) ] - if len(v1_coords) + len(a01_coords) + len(b01_coords) == 0: + if ( + len(v1_coords) + len(a01_coords) + len(b01_q7_coords) + len(b01_q10_coords) == 0 + and enabled_devices + ): raise ConfigEntryNotReady( "No devices were able to successfully setup", translation_domain=DOMAIN, translation_key="no_coordinators", ) - entry.runtime_data = RoborockCoordinators(v1_coords, a01_coords, b01_coords) + entry.runtime_data = RoborockCoordinators( + v1_coords, a01_coords, b01_q7_coords, b01_q10_coords + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -164,6 +190,15 @@ async def shutdown_roborock(_: Event | None = None) -> None: return True +def _is_device_disabled( + device_registry: dr.DeviceRegistry, + device: RoborockDevice, +) -> bool: + """Check if a device is disabled in the device registry.""" + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, device.duid)}) + return device_entry is not None and device_entry.disabled + + def _remove_stale_devices( hass: HomeAssistant, entry: RoborockConfigEntry, @@ -229,6 +264,7 @@ def build_setup_functions( RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 | RoborockDataUpdateCoordinatorB01 + | RoborockB01Q10UpdateCoordinator | None, ] ]: @@ -237,6 +273,7 @@ def build_setup_functions( RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 | RoborockDataUpdateCoordinatorB01 + | RoborockB01Q10UpdateCoordinator ] = [] for device in devices: _LOGGER.debug("Creating device %s: %s", device.name, device) @@ -258,6 +295,12 @@ def build_setup_functions( hass, entry, device, device.b01_q7_properties ) ) + elif device.b01_q10_properties is not None: + coordinators.append( + RoborockB01Q10UpdateCoordinator( + hass, entry, device, device.b01_q10_properties + ) + ) else: _LOGGER.warning( "Not adding device %s because its protocol version %s or category %s is not supported", @@ -272,11 +315,13 @@ def build_setup_functions( async def setup_coordinator( coordinator: RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 - | RoborockDataUpdateCoordinatorB01, + | RoborockDataUpdateCoordinatorB01 + | RoborockB01Q10UpdateCoordinator, ) -> ( RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 | RoborockDataUpdateCoordinatorB01 + | RoborockB01Q10UpdateCoordinator | None ): """Set up a single coordinator.""" diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index dfeae5f9dd9f9a..114656a6d17abb 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from roborock.data import CleanFluidStatus, RoborockStateCode +from roborock.roborock_message import RoborockZeoProtocol from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -15,9 +16,15 @@ from homeassistant.const import ATTR_BATTERY_CHARGING, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType -from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator -from .entity import RoborockCoordinatedEntityV1 +from .coordinator import ( + RoborockConfigEntry, + RoborockDataUpdateCoordinator, + RoborockDataUpdateCoordinatorA01, + RoborockWashingMachineUpdateCoordinator, +) +from .entity import RoborockCoordinatedEntityA01, RoborockCoordinatedEntityV1 from .models import DeviceState PARALLEL_UPDATES = 0 @@ -34,6 +41,14 @@ class RoborockBinarySensorDescription(BinarySensorEntityDescription): """Whether this sensor is for the dock.""" +@dataclass(frozen=True, kw_only=True) +class RoborockBinarySensorDescriptionA01(BinarySensorEntityDescription): + """A class that describes Roborock A01 binary sensors.""" + + data_protocol: RoborockZeoProtocol + value_fn: Callable[[StateType], bool] + + BINARY_SENSOR_DESCRIPTIONS = [ RoborockBinarySensorDescription( key="dry_status", @@ -111,13 +126,33 @@ class RoborockBinarySensorDescription(BinarySensorEntityDescription): ] +ZEO_BINARY_SENSOR_DESCRIPTIONS: list[RoborockBinarySensorDescriptionA01] = [ + RoborockBinarySensorDescriptionA01( + key="detergent_empty", + data_protocol=RoborockZeoProtocol.DETERGENT_EMPTY, + device_class=BinarySensorDeviceClass.PROBLEM, + translation_key="detergent_empty", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=bool, + ), + RoborockBinarySensorDescriptionA01( + key="softener_empty", + data_protocol=RoborockZeoProtocol.SOFTENER_EMPTY, + device_class=BinarySensorDeviceClass.PROBLEM, + translation_key="softener_empty", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=bool, + ), +] + + async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Roborock vacuum binary sensors.""" - async_add_entities( + entities: list[BinarySensorEntity] = [ RoborockBinarySensorEntity( coordinator, description, @@ -125,7 +160,18 @@ async def async_setup_entry( for coordinator in config_entry.runtime_data.v1 for description in BINARY_SENSOR_DESCRIPTIONS if description.value_fn(coordinator.data) is not None + ] + entities.extend( + RoborockBinarySensorEntityA01( + coordinator, + description, + ) + for coordinator in config_entry.runtime_data.a01 + if isinstance(coordinator, RoborockWashingMachineUpdateCoordinator) + for description in ZEO_BINARY_SENSOR_DESCRIPTIONS + if description.data_protocol in coordinator.request_protocols ) + async_add_entities(entities) class RoborockBinarySensorEntity(RoborockCoordinatedEntityV1, BinarySensorEntity): @@ -150,3 +196,24 @@ def __init__( def is_on(self) -> bool: """Return the value reported by the sensor.""" return bool(self.entity_description.value_fn(self.coordinator.data)) + + +class RoborockBinarySensorEntityA01(RoborockCoordinatedEntityA01, BinarySensorEntity): + """Representation of a A01 Roborock binary sensor.""" + + entity_description: RoborockBinarySensorDescriptionA01 + + def __init__( + self, + coordinator: RoborockDataUpdateCoordinatorA01, + description: RoborockBinarySensorDescriptionA01, + ) -> None: + """Initialize the entity.""" + self.entity_description = description + super().__init__(f"{description.key}_{coordinator.duid_slug}", coordinator) + + @property + def is_on(self) -> bool: + """Return the value reported by the sensor.""" + value = self.coordinator.data[self.entity_description.data_protocol] + return self.entity_description.value_fn(value) diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index 2365a86c703a91..65f2e1713596ce 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -10,6 +10,7 @@ from roborock.devices.traits.v1.consumeable import ConsumableAttribute from roborock.exceptions import RoborockException +from roborock.roborock_message import RoborockZeoProtocol from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory @@ -18,8 +19,13 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN -from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator -from .entity import RoborockEntity, RoborockEntityV1 +from .coordinator import ( + RoborockConfigEntry, + RoborockDataUpdateCoordinator, + RoborockDataUpdateCoordinatorA01, + RoborockWashingMachineUpdateCoordinator, +) +from .entity import RoborockCoordinatedEntityA01, RoborockEntity, RoborockEntityV1 _LOGGER = logging.getLogger(__name__) @@ -65,6 +71,32 @@ class RoborockButtonDescription(ButtonEntityDescription): ] +@dataclass(frozen=True, kw_only=True) +class RoborockButtonDescriptionA01(ButtonEntityDescription): + """Describes a Roborock A01 button entity.""" + + data_protocol: RoborockZeoProtocol + + +ZEO_BUTTON_DESCRIPTIONS = [ + RoborockButtonDescriptionA01( + key="start", + data_protocol=RoborockZeoProtocol.START, + translation_key="start", + ), + RoborockButtonDescriptionA01( + key="pause", + data_protocol=RoborockZeoProtocol.PAUSE, + translation_key="pause", + ), + RoborockButtonDescriptionA01( + key="shutdown", + data_protocol=RoborockZeoProtocol.SHUTDOWN, + translation_key="shutdown", + ), +] + + async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, @@ -98,6 +130,15 @@ async def async_setup_entry( ) for routine in routines ), + ( + RoborockButtonEntityA01( + coordinator, + description, + ) + for coordinator in config_entry.runtime_data.a01 + if isinstance(coordinator, RoborockWashingMachineUpdateCoordinator) + for description in ZEO_BUTTON_DESCRIPTIONS + ), ) ) @@ -160,3 +201,35 @@ def __init__( async def async_press(self, **kwargs: Any) -> None: """Press the button.""" await self._coordinator.execute_routines(self._routine_id) + + +class RoborockButtonEntityA01(RoborockCoordinatedEntityA01, ButtonEntity): + """A class to define Roborock A01 button entities.""" + + entity_description: RoborockButtonDescriptionA01 + + def __init__( + self, + coordinator: RoborockDataUpdateCoordinatorA01, + entity_description: RoborockButtonDescriptionA01, + ) -> None: + """Create an A01 button entity.""" + self.entity_description = entity_description + super().__init__( + f"{entity_description.key}_{coordinator.duid_slug}", coordinator + ) + + async def async_press(self) -> None: + """Press the button.""" + try: + await self.coordinator.api.set_value( # type: ignore[attr-defined] + self.entity_description.data_protocol, + 1, + ) + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="button_press_failed", + ) from err + finally: + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index 9f066593c2f3ae..3cf0848ca45734 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -188,7 +188,9 @@ async def async_step_reauth( self._username = entry_data[CONF_USERNAME] assert self._username self._client = RoborockApiClient( - self._username, session=async_get_clientsession(self.hass) + self._username, + base_url=entry_data[CONF_BASE_URL], + session=async_get_clientsession(self.hass), ) return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index 3cfe6f4899f93a..9393b58d6d935f 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -59,6 +59,7 @@ A01_UPDATE_INTERVAL = timedelta(minutes=1) +Q10_UPDATE_INTERVAL = timedelta(minutes=1) V1_CLOUD_IN_CLEANING_INTERVAL = timedelta(seconds=30) V1_CLOUD_NOT_CLEANING_INTERVAL = timedelta(minutes=1) V1_LOCAL_IN_CLEANING_INTERVAL = timedelta(seconds=15) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 6100d997d63ced..e20670706c96e7 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -12,7 +12,7 @@ from roborock.data import HomeDataScene from roborock.devices.device import RoborockDevice from roborock.devices.traits.a01 import DyadApi, ZeoApi -from roborock.devices.traits.b01 import Q7PropertiesApi +from roborock.devices.traits.b01 import Q7PropertiesApi, Q10PropertiesApi from roborock.devices.traits.v1 import PropertiesApi from roborock.exceptions import RoborockDeviceBusy, RoborockException from roborock.roborock_message import ( @@ -40,12 +40,13 @@ A01_UPDATE_INTERVAL, DOMAIN, IMAGE_CACHE_INTERVAL, + Q10_UPDATE_INTERVAL, V1_CLOUD_IN_CLEANING_INTERVAL, V1_CLOUD_NOT_CLEANING_INTERVAL, V1_LOCAL_IN_CLEANING_INTERVAL, V1_LOCAL_NOT_CLEANING_INTERVAL, ) -from .models import DeviceState +from .models import DeviceState, get_device_info SCAN_INTERVAL = timedelta(seconds=30) @@ -64,17 +65,19 @@ class RoborockCoordinators: v1: list[RoborockDataUpdateCoordinator] a01: list[RoborockDataUpdateCoordinatorA01] - b01: list[RoborockDataUpdateCoordinatorB01] + b01_q7: list[RoborockB01Q7UpdateCoordinator] + b01_q10: list[RoborockB01Q10UpdateCoordinator] def values( self, ) -> list[ RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 - | RoborockDataUpdateCoordinatorB01 + | RoborockB01Q7UpdateCoordinator + | RoborockB01Q10UpdateCoordinator ]: """Return all coordinators.""" - return self.v1 + self.a01 + self.b01 + return self.v1 + self.a01 + self.b01_q7 + self.b01_q10 type RoborockConfigEntry = ConfigEntry[RoborockCoordinators] @@ -103,14 +106,7 @@ def __init__( ) self._device = device self.properties_api = properties_api - self.device_info = DeviceInfo( - name=self._device.device_info.name, - identifiers={(DOMAIN, self.duid)}, - manufacturer="Roborock", - model=self._device.product.model, - model_id=self._device.product.model, - sw_version=self._device.device_info.fv, - ) + self.device_info = get_device_info(device) if mac := properties_api.network_info.mac: self.device_info[ATTR_CONNECTIONS] = { (dr.CONNECTION_NETWORK_MAC, dr.format_mac(mac)) @@ -225,7 +221,6 @@ async def _update_device_prop(self) -> None: self.properties_api.smart_wash_params, self.properties_api.sound_volume, self.properties_api.child_lock, - self.properties_api.dust_collection_mode, self.properties_api.flow_led_status, self.properties_api.valley_electricity_timer, ) @@ -385,13 +380,7 @@ def __init__( update_interval=A01_UPDATE_INTERVAL, ) self._device = device - self.device_info = DeviceInfo( - name=device.name, - identifiers={(DOMAIN, device.duid)}, - manufacturer="Roborock", - model=device.product.model, - sw_version=device.device_info.fv, - ) + self.device_info = get_device_info(device) self.request_protocols: list[_V] = [] @cached_property @@ -432,6 +421,18 @@ def __init__( RoborockZeoProtocol.COUNTDOWN, RoborockZeoProtocol.WASHING_LEFT, RoborockZeoProtocol.ERROR, + RoborockZeoProtocol.TIMES_AFTER_CLEAN, + RoborockZeoProtocol.DETERGENT_EMPTY, + RoborockZeoProtocol.SOFTENER_EMPTY, + RoborockZeoProtocol.DETERGENT_TYPE, + RoborockZeoProtocol.SOFTENER_TYPE, + RoborockZeoProtocol.MODE, + RoborockZeoProtocol.PROGRAM, + RoborockZeoProtocol.TEMP, + RoborockZeoProtocol.RINSE_TIMES, + RoborockZeoProtocol.SPIN_LEVEL, + RoborockZeoProtocol.DRYING_MODE, + RoborockZeoProtocol.SOUND_SET, ] async def _async_update_data( @@ -505,13 +506,7 @@ def __init__( update_interval=A01_UPDATE_INTERVAL, ) self._device = device - self.device_info = DeviceInfo( - name=device.name, - identifiers={(DOMAIN, device.duid)}, - manufacturer="Roborock", - model=device.product.model, - sw_version=device.device_info.fv, - ) + self.device_info = get_device_info(device) @cached_property def duid(self) -> str: @@ -574,3 +569,67 @@ async def _async_update_data( translation_key="update_data_fail", ) return data + + +class RoborockB01Q10UpdateCoordinator(DataUpdateCoordinator[None]): + """Coordinator for B01 Q10 devices. + + The Q10 uses push-based MQTT status updates. The `refresh()` call sends a + REQUEST_DPS command (fire-and-forget) to solicit a status push from the + device; the response arrives asynchronously through the MQTT subscribe loop. + + Entities manage their own state updates through listening to individual + traits on the Q10PropertiesApi. Each trait has its own update listener + that will notify the entity of changes. + """ + + config_entry: RoborockConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: RoborockConfigEntry, + device: RoborockDevice, + api: Q10PropertiesApi, + ) -> None: + """Initialize RoborockB01Q10UpdateCoordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=Q10_UPDATE_INTERVAL, + ) + self._device = device + self.api = api + self.device_info = get_device_info(device) + + 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. + """ + try: + await self.api.refresh() + except RoborockException as ex: + _LOGGER.debug("Failed to request Q10 data: %s", ex) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="request_fail", + ) from ex + + @cached_property + def duid(self) -> str: + """Get the unique id of the device as specified by Roborock.""" + return self._device.duid + + @cached_property + def duid_slug(self) -> str: + """Get the slug of the duid.""" + return slugify(self.duid) + + @property + def device(self) -> RoborockDevice: + """Get the RoborockDevice.""" + return self._device diff --git a/homeassistant/components/roborock/entity.py b/homeassistant/components/roborock/entity.py index 2dea15e1e96d91..24d5904b97cf39 100644 --- a/homeassistant/components/roborock/entity.py +++ b/homeassistant/components/roborock/entity.py @@ -2,8 +2,8 @@ from typing import Any -from roborock.data import Status from roborock.devices.traits.v1.command import CommandTrait +from roborock.devices.traits.v1.status import StatusTrait from roborock.exceptions import RoborockException from roborock.roborock_typing import RoborockCommand @@ -14,9 +14,10 @@ from .const import DOMAIN from .coordinator import ( + RoborockB01Q7UpdateCoordinator, + RoborockB01Q10UpdateCoordinator, RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01, - RoborockDataUpdateCoordinatorB01, ) @@ -94,7 +95,7 @@ def __init__( self._attr_unique_id = unique_id @property - def _device_status(self) -> Status: + def _device_status(self) -> StatusTrait: """Return the status of the device.""" data = self.coordinator.data return data.status @@ -130,21 +131,41 @@ def __init__( self._attr_unique_id = unique_id -class RoborockCoordinatedEntityB01( - RoborockEntity, CoordinatorEntity[RoborockDataUpdateCoordinatorB01] +class RoborockCoordinatedEntityB01Q7( + RoborockEntity, CoordinatorEntity[RoborockB01Q7UpdateCoordinator] ): """Representation of coordinated Roborock Entity.""" def __init__( self, unique_id: str, - coordinator: RoborockDataUpdateCoordinatorB01, + coordinator: RoborockB01Q7UpdateCoordinator, ) -> None: """Initialize the coordinated Roborock Device.""" + CoordinatorEntity.__init__(self, coordinator=coordinator) RoborockEntity.__init__( self, unique_id=unique_id, device_info=coordinator.device_info, ) + self._attr_unique_id = unique_id + + +class RoborockCoordinatedEntityB01Q10( + RoborockEntity, CoordinatorEntity[RoborockB01Q10UpdateCoordinator] +): + """Representation of coordinated Roborock Q10 Entity.""" + + def __init__( + self, + unique_id: str, + coordinator: RoborockB01Q10UpdateCoordinator, + ) -> None: + """Initialize the coordinated Roborock Device.""" CoordinatorEntity.__init__(self, coordinator=coordinator) + RoborockEntity.__init__( + self, + unique_id=unique_id, + device_info=coordinator.device_info, + ) self._attr_unique_id = unique_id diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index c5368803aefe55..48891a5e5f5857 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==4.14.0", + "python-roborock==4.25.0", "vacuum-map-parser-roborock==0.1.4" ] } diff --git a/homeassistant/components/roborock/models.py b/homeassistant/components/roborock/models.py index 6715e370a5d6fb..c8ffc3db7f9d81 100644 --- a/homeassistant/components/roborock/models.py +++ b/homeassistant/components/roborock/models.py @@ -12,18 +12,35 @@ HomeDataDevice, HomeDataProduct, NetworkInfo, - Status, ) +from roborock.devices.device import RoborockDevice +from roborock.devices.traits.v1.status import StatusTrait from vacuum_map_parser_base.map_data import MapData +from homeassistant.helpers.device_registry import DeviceInfo + +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) +def get_device_info(device: RoborockDevice) -> DeviceInfo: + """Create a DeviceInfo for a Roborock device.""" + return DeviceInfo( + name=device.name, + identifiers={(DOMAIN, device.duid)}, + manufacturer="Roborock", + model=device.product.model, + model_id=device.product.model, + sw_version=device.device_info.fv, + ) + + @dataclass class DeviceState: """Data about the current state of a device.""" - status: Status + status: StatusTrait dnd_timer: DnDTimer consumable: Consumable clean_summary: CleanSummaryWithDetail diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 341dea0b267ef3..0ff27d8145f945 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -3,21 +3,35 @@ import asyncio from collections.abc import Awaitable, Callable from dataclasses import dataclass +import logging from typing import Any from roborock import B01Props, CleanTypeMapping -from roborock.data import RoborockDockDustCollectionModeCode, WaterLevelMapping +from roborock.data import ( + RoborockDockDustCollectionModeCode, + RoborockEnum, + WaterLevelMapping, + ZeoDetergentType, + ZeoDryingMode, + ZeoMode, + ZeoProgram, + ZeoRinse, + ZeoSoftenerType, + ZeoSpin, + ZeoTemperature, +) from roborock.devices.traits.b01 import Q7PropertiesApi from roborock.devices.traits.v1 import PropertiesApi from roborock.devices.traits.v1.home import HomeTrait from roborock.devices.traits.v1.maps import MapsTrait from roborock.exceptions import RoborockException +from roborock.roborock_message import RoborockZeoProtocol from roborock.roborock_typing import RoborockCommand from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, MAP_SLEEP @@ -25,11 +39,18 @@ RoborockB01Q7UpdateCoordinator, RoborockConfigEntry, RoborockDataUpdateCoordinator, + RoborockDataUpdateCoordinatorA01, +) +from .entity import ( + RoborockCoordinatedEntityA01, + RoborockCoordinatedEntityB01Q7, + RoborockCoordinatedEntityV1, ) -from .entity import RoborockCoordinatedEntityB01, RoborockCoordinatedEntityV1 PARALLEL_UPDATES = 0 +_LOGGER = logging.getLogger(__name__) + @dataclass(frozen=True, kw_only=True) class RoborockSelectDescription(SelectEntityDescription): @@ -65,6 +86,16 @@ class RoborockB01SelectDescription(SelectEntityDescription): """Function to get all options of the select entity or returns None if not supported.""" +@dataclass(frozen=True, kw_only=True) +class RoborockSelectDescriptionA01(SelectEntityDescription): + """Class to describe a Roborock A01 select entity.""" + + # The protocol that the select entity will send to the api. + data_protocol: RoborockZeoProtocol + # Enum class for the select entity + enum_class: type[RoborockEnum] + + B01_SELECT_DESCRIPTIONS: list[RoborockB01SelectDescription] = [ RoborockB01SelectDescription( key="water_flow", @@ -92,25 +123,31 @@ class RoborockB01SelectDescription(SelectEntityDescription): key="water_box_mode", translation_key="mop_intensity", api_command=RoborockCommand.SET_WATER_BOX_CUSTOM_MODE, - value_fn=lambda api: api.status.water_box_mode_name, + value_fn=lambda api: api.status.water_mode_name, entity_category=EntityCategory.CONFIG, options_lambda=lambda api: ( - api.status.water_box_mode.keys() - if api.status.water_box_mode is not None + [mode.value for mode in api.status.water_mode_options] + if api.status.water_mode_options else None ), - parameter_lambda=lambda key, api: [api.status.get_mop_intensity_code(key)], + parameter_lambda=lambda key, api: [ + {v: k for k, v in api.status.water_mode_mapping.items()}[key] + ], ), RoborockSelectDescription( key="mop_mode", translation_key="mop_mode", api_command=RoborockCommand.SET_MOP_MODE, - value_fn=lambda api: api.status.mop_mode_name, + value_fn=lambda api: api.status.mop_route_name, entity_category=EntityCategory.CONFIG, options_lambda=lambda api: ( - api.status.mop_mode.keys() if api.status.mop_mode is not None else None + [mode.value for mode in api.status.mop_route_options] + if api.status.mop_route_options + else None ), - parameter_lambda=lambda key, api: [api.status.get_mop_mode_code(key)], + parameter_lambda=lambda key, api: [ + {v: k for k, v in api.status.mop_route_mapping.items()}[key] + ], ), RoborockSelectDescription( key="dust_collection_mode", @@ -133,6 +170,66 @@ class RoborockB01SelectDescription(SelectEntityDescription): ] +A01_SELECT_DESCRIPTIONS: list[RoborockSelectDescriptionA01] = [ + RoborockSelectDescriptionA01( + key="program", + data_protocol=RoborockZeoProtocol.PROGRAM, + translation_key="program", + entity_category=EntityCategory.CONFIG, + enum_class=ZeoProgram, + ), + RoborockSelectDescriptionA01( + key="mode", + data_protocol=RoborockZeoProtocol.MODE, + translation_key="mode", + entity_category=EntityCategory.CONFIG, + enum_class=ZeoMode, + ), + RoborockSelectDescriptionA01( + key="temperature", + data_protocol=RoborockZeoProtocol.TEMP, + translation_key="temperature", + entity_category=EntityCategory.CONFIG, + enum_class=ZeoTemperature, + ), + RoborockSelectDescriptionA01( + key="drying_mode", + data_protocol=RoborockZeoProtocol.DRYING_MODE, + translation_key="drying_mode", + entity_category=EntityCategory.CONFIG, + enum_class=ZeoDryingMode, + ), + RoborockSelectDescriptionA01( + key="spin_level", + data_protocol=RoborockZeoProtocol.SPIN_LEVEL, + translation_key="spin_level", + entity_category=EntityCategory.CONFIG, + enum_class=ZeoSpin, + ), + RoborockSelectDescriptionA01( + key="rinse_times", + data_protocol=RoborockZeoProtocol.RINSE_TIMES, + translation_key="rinse_times", + entity_category=EntityCategory.CONFIG, + enum_class=ZeoRinse, + ), + RoborockSelectDescriptionA01( + key="detergent_type", + data_protocol=RoborockZeoProtocol.DETERGENT_TYPE, + translation_key="detergent_type", + entity_category=EntityCategory.CONFIG, + enum_class=ZeoDetergentType, + ), + RoborockSelectDescriptionA01( + key="softener_type", + data_protocol=RoborockZeoProtocol.SOFTENER_TYPE, + translation_key="softener_type", + entity_category=EntityCategory.CONFIG, + enum_class=ZeoSoftenerType, + ), +] + + async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, @@ -159,14 +256,19 @@ async def async_setup_entry( ) async_add_entities( RoborockB01SelectEntity(coordinator, description, options) - for coordinator in config_entry.runtime_data.b01 + for coordinator in config_entry.runtime_data.b01_q7 for description in B01_SELECT_DESCRIPTIONS - if isinstance(coordinator, RoborockB01Q7UpdateCoordinator) if (options := description.options_lambda(coordinator.api)) is not None ) + async_add_entities( + RoborockSelectEntityA01(coordinator, description) + for coordinator in config_entry.runtime_data.a01 + for description in A01_SELECT_DESCRIPTIONS + if description.data_protocol in coordinator.request_protocols + ) -class RoborockB01SelectEntity(RoborockCoordinatedEntityB01, SelectEntity): +class RoborockB01SelectEntity(RoborockCoordinatedEntityB01Q7, SelectEntity): """Select entity for Roborock B01 devices.""" entity_description: RoborockB01SelectDescription @@ -303,3 +405,64 @@ def current_option(self) -> str | None: if current_map_info := self._home_trait.current_map_data: return current_map_info.name or f"Map {current_map_info.map_flag}" return None + + +class RoborockSelectEntityA01(RoborockCoordinatedEntityA01, SelectEntity): + """A class to let you set options on a Roborock A01 device.""" + + entity_description: RoborockSelectDescriptionA01 + + def __init__( + self, + coordinator: RoborockDataUpdateCoordinatorA01, + entity_description: RoborockSelectDescriptionA01, + ) -> None: + """Create an A01 select entity.""" + self.entity_description = entity_description + super().__init__( + f"{entity_description.key}_{coordinator.duid_slug}", + coordinator, + ) + self._attr_options = list(entity_description.enum_class.keys()) + + async def async_select_option(self, option: str) -> None: + """Set the option.""" + # Get the protocol value for the selected option + option_values = self.entity_description.enum_class.as_dict() + if option not in option_values: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="select_option_failed", + ) + value = option_values[option] + try: + await self.coordinator.api.set_value( # type: ignore[attr-defined] + self.entity_description.data_protocol, + value, + ) + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={ + "command": self.entity_description.key, + }, + ) from err + + await self.coordinator.async_request_refresh() + + @property + def current_option(self) -> str | None: + """Get the current status of the select entity from coordinator data.""" + if self.entity_description.data_protocol not in self.coordinator.data: + return None + + current_value = self.coordinator.data[self.entity_description.data_protocol] + if current_value is None: + return None + _LOGGER.debug( + "current_value: %s for %s", + current_value, + self.entity_description.key, + ) + return str(current_value) diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 0b05996cf8c6a3..bb0240a78da14a 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -33,14 +33,16 @@ from homeassistant.helpers.typing import StateType from .coordinator import ( + RoborockB01Q7UpdateCoordinator, RoborockConfigEntry, RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01, - RoborockDataUpdateCoordinatorB01, + RoborockWashingMachineUpdateCoordinator, + RoborockWetDryVacUpdateCoordinator, ) from .entity import ( RoborockCoordinatedEntityA01, - RoborockCoordinatedEntityB01, + RoborockCoordinatedEntityB01Q7, RoborockCoordinatedEntityV1, RoborockEntity, ) @@ -252,7 +254,7 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: ), ] -A01_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [ +DYAD_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [ RoborockSensorDescriptionA01( key="status", data_protocol=RoborockDyadDataProtocol.STATUS, @@ -303,6 +305,9 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: translation_key="total_cleaning_time", entity_category=EntityCategory.DIAGNOSTIC, ), +] + +ZEO_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [ RoborockSensorDescriptionA01( key="state", data_protocol=RoborockZeoProtocol.STATE, @@ -335,6 +340,12 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: entity_category=EntityCategory.DIAGNOSTIC, options=ZeoError.keys(), ), + RoborockSensorDescriptionA01( + key="times_after_clean", + data_protocol=RoborockZeoProtocol.TIMES_AFTER_CLEAN, + translation_key="times_after_clean", + entity_category=EntityCategory.DIAGNOSTIC, + ), ] Q7_B01_SENSOR_DESCRIPTIONS = [ @@ -418,12 +429,23 @@ async def async_setup_entry( description, ) for coordinator in coordinators.a01 - for description in A01_SENSOR_DESCRIPTIONS + if isinstance(coordinator, RoborockWetDryVacUpdateCoordinator) + for description in DYAD_SENSOR_DESCRIPTIONS + if description.data_protocol in coordinator.request_protocols + ) + entities.extend( + RoborockSensorEntityA01( + coordinator, + description, + ) + for coordinator in coordinators.a01 + if isinstance(coordinator, RoborockWashingMachineUpdateCoordinator) + for description in ZEO_SENSOR_DESCRIPTIONS if description.data_protocol in coordinator.request_protocols ) entities.extend( - RoborockSensorEntityB01(coordinator, description) - for coordinator in coordinators.b01 + RoborockSensorEntityB01Q7(coordinator, description) + for coordinator in coordinators.b01_q7 for description in Q7_B01_SENSOR_DESCRIPTIONS if description.value_fn(coordinator.data) is not None ) @@ -515,14 +537,14 @@ def native_value(self) -> StateType: return self.coordinator.data[self.entity_description.data_protocol] -class RoborockSensorEntityB01(RoborockCoordinatedEntityB01, SensorEntity): - """Representation of a B01 Roborock sensor.""" +class RoborockSensorEntityB01Q7(RoborockCoordinatedEntityB01Q7, SensorEntity): + """Representation of a B01 Q7 Roborock sensor.""" entity_description: RoborockSensorDescriptionB01 def __init__( self, - coordinator: RoborockDataUpdateCoordinatorB01, + coordinator: RoborockB01Q7UpdateCoordinator, description: RoborockSensorDescriptionB01, ) -> None: """Initialize the entity.""" diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 7c051ba1299349..64f09e5dcb65fe 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -50,6 +50,13 @@ "clean_fluid_empty": { "name": "Cleaning fluid" }, + "detergent_empty": { + "name": "Detergent", + "state": { + "off": "Available", + "on": "[%key:common::state::empty%]" + } + }, "dirty_box_full": { "name": "Dirty water box" }, @@ -62,6 +69,13 @@ "mop_drying_status": { "name": "Mop drying" }, + "softener_empty": { + "name": "Softener", + "state": { + "off": "Available", + "on": "[%key:common::state::empty%]" + } + }, "water_box_attached": { "name": "Water box attached" }, @@ -70,6 +84,9 @@ } }, "button": { + "pause": { + "name": "Pause" + }, "reset_air_filter_consumable": { "name": "Reset air filter consumable" }, @@ -81,6 +98,12 @@ }, "reset_side_brush_consumable": { "name": "Reset side brush consumable" + }, + "shutdown": { + "name": "Shutdown" + }, + "start": { + "name": "Start" } }, "number": { @@ -97,6 +120,25 @@ "vacuum": "Vacuum only" } }, + "detergent_type": { + "name": "Detergent type", + "state": { + "empty": "[%key:common::state::empty%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]" + } + }, + "drying_mode": { + "name": "Drying mode", + "state": { + "iron": "Iron", + "none": "No drying", + "quick": "Quick", + "store": "Store", + "time_dry": "Time dry" + } + }, "dust_collection_mode": { "name": "Empty mode", "state": { @@ -106,6 +148,19 @@ "smart": "Smart" } }, + "mode": { + "name": "Operating mode", + "state": { + "drain": "Drain", + "dry": "Dry", + "heavy": "Heavy", + "pre_wash": "Pre-wash", + "rinse_spin": "Rinse & spin", + "spin": "Spin", + "wash": "Wash", + "wash_and_dry": "Wash and dry" + } + }, "mop_intensity": { "name": "Mop intensity", "state": { @@ -118,9 +173,12 @@ "max": "Max", "medium": "[%key:common::state::medium%]", "mild": "Mild", + "min": "Min", "moderate": "Moderate", "off": "[%key:common::state::off%]", + "slight": "Slight", "smart_mode": "[%key:component::roborock::entity::select::mop_mode::state::smart_mode%]", + "standard": "[%key:component::roborock::entity::select::mop_mode::state::standard%]", "vac_followed_by_mop": "Vacuum followed by mop" } }, @@ -135,9 +193,90 @@ "standard": "Standard" } }, + "program": { + "name": "Wash program", + "state": { + "air_refresh": "Air refresh", + "anti_allergen": "Anti-allergen", + "anti_mites": "Anti-mites", + "baby_care": "Baby care", + "bedding": "Bedding", + "boiling_wash": "Boiling wash", + "bra": "Bra", + "cotton_linen": "Cotton/Linen", + "custom": "Custom", + "down": "Down", + "down_clean": "Down clean", + "exo_40_60": "Exo 40/60", + "gentle": "Gentle", + "intensive": "Intensive", + "new_clothes": "New clothes", + "night": "Night", + "panties": "Panties", + "quick": "Quick", + "rinse_and_spin": "Rinse and spin", + "sanitize": "Sanitize", + "season": "Season", + "shirts": "Shirts", + "silk": "Silk", + "socks": "Socks", + "sportswear": "Sportswear", + "stain_removal": "Stain removal", + "standard": "Standard", + "synthetics": "Synthetics", + "t_shirts": "T-shirts", + "towels": "Towels", + "twenty_c": "20°C", + "underwear": "Underwear", + "warming": "Warming", + "wool": "Wool" + } + }, + "rinse_times": { + "name": "Rinse times", + "state": { + "high": "4", + "low": "2", + "max": "5", + "mid": "3", + "min": "1", + "none": "Default" + } + }, "selected_map": { "name": "Selected map" }, + "softener_type": { + "name": "Softener type", + "state": { + "empty": "[%key:common::state::empty%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]" + } + }, + "spin_level": { + "name": "Spin level", + "state": { + "high": "1000 RPM", + "max": "1400 RPM", + "mid": "800 RPM", + "none": "Default", + "very_high": "1200 RPM", + "very_low": "600 RPM" + } + }, + "temperature": { + "name": "Water temperature", + "state": { + "30": "30°C", + "40": "40°C", + "60": "60°C", + "90": "90°C", + "auto": "[%key:common::state::auto%]", + "cold": "Cold" + } + }, "water_flow": { "name": "Water flow", "state": { @@ -304,6 +443,9 @@ "strainer_time_left": { "name": "Strainer time left" }, + "times_after_clean": { + "name": "Times after clean" + }, "total_cleaning_area": { "name": "Total cleaning area" }, @@ -326,7 +468,7 @@ "clear_water_box_hoare": "Check the clean water tank", "cliff_sensor_error": "Cliff sensor error", "collect_dust_error_3": "Clean auto-empty dock", - "collect_dust_error_4": "Auto empty dock voltage error", + "collect_dust_error_4": "Auto-empty dock voltage error", "compass_error": "Strong magnetic field detected", "dirty_water_box_hoare": "Check the dirty water tank", "dock": "Dock not connected to power", @@ -372,14 +514,14 @@ "communication_error": "Communication error", "door_lock_error": "Door lock error", "drain_error": "Drain error", - "drying_error": "Drying error", - "drying_error_e_12": "Drying error E12", + "drying_error": "Drying error: check air inlet temperature sensor", + "drying_error_e_12": "Drying error: check air outlet temperature sensor", "drying_error_e_13": "Drying error E13", - "drying_error_e_14": "Drying error E14", - "drying_error_e_15": "Drying error E15", - "drying_error_e_16": "Drying error E16", - "drying_error_restart": "Restart the washer", - "drying_error_water_flow": "Check water flow", + "drying_error_e_14": "Drying error: check inlet condenser temperature sensor", + "drying_error_e_15": "Drying error: check heating element or turntable", + "drying_error_e_16": "Drying error: check drying fan", + "drying_error_restart": "Drying error: restart the washer", + "drying_error_water_flow": "Drying error: check water flow", "heating_error": "Heating error", "inverter_error": "Inverter error", "none": "[%key:component::roborock::entity::sensor::vacuum_error::state::none%]", @@ -417,6 +559,9 @@ "off_peak_switch": { "name": "Off-peak charging" }, + "sound_setting": { + "name": "Sound setting" + }, "status_indicator": { "name": "Status indicator light" } @@ -448,6 +593,7 @@ "max_plus": "Max plus", "medium": "[%key:common::state::medium%]", "off": "[%key:common::state::off%]", + "off_raise_main_brush": "Off (raised brush)", "quiet": "Quiet", "silent": "Silent", "smart_mode": "[%key:component::roborock::entity::select::mop_mode::state::smart_mode%]", @@ -460,15 +606,24 @@ } }, "exceptions": { + "button_press_failed": { + "message": "Failed to press button" + }, "command_failed": { "message": "Error while calling {command}" }, "home_data_fail": { "message": "Failed to get Roborock home data" }, + "invalid_command": { + "message": "Invalid command {command}" + }, "invalid_credentials": { "message": "Invalid credentials." }, + "invalid_fan_speed": { + "message": "Invalid fan speed: {fan_speed}" + }, "invalid_user_agreement": { "message": "User agreement must be accepted again. Open your Roborock app and accept the agreement." }, @@ -487,6 +642,15 @@ "position_not_found": { "message": "Robot position not found" }, + "request_fail": { + "message": "Failed to request data" + }, + "segment_id_parse_error": { + "message": "Invalid segment ID format: {segment_id}" + }, + "select_option_failed": { + "message": "Failed to set selected option" + }, "update_data_fail": { "message": "Failed to update data" }, @@ -500,7 +664,6 @@ "title": "Cloud API used" } }, - "options": { "step": { "drawables": { diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index b1d61461eb64a0..27f901740ec44d 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -10,6 +10,7 @@ from roborock.devices.traits.v1 import PropertiesApi from roborock.devices.traits.v1.common import RoborockSwitchBase from roborock.exceptions import RoborockException +from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory @@ -18,8 +19,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN -from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator -from .entity import RoborockEntityV1 +from .coordinator import ( + RoborockConfigEntry, + RoborockDataUpdateCoordinator, + RoborockDataUpdateCoordinatorA01, +) +from .entity import RoborockCoordinatedEntityA01, RoborockEntityV1 _LOGGER = logging.getLogger(__name__) @@ -67,12 +72,30 @@ class RoborockSwitchDescription(SwitchEntityDescription): ] +@dataclass(frozen=True, kw_only=True) +class RoborockSwitchDescriptionA01(SwitchEntityDescription): + """Class to describe a Roborock A01 switch entity.""" + + data_protocol: RoborockDyadDataProtocol | RoborockZeoProtocol + + +A01_SWITCH_DESCRIPTIONS: list[RoborockSwitchDescriptionA01] = [ + RoborockSwitchDescriptionA01( + key="sound_setting", + data_protocol=RoborockZeoProtocol.SOUND_SET, + translation_key="sound_setting", + entity_category=EntityCategory.CONFIG, + ), +] + + async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roborock switch platform.""" + # V1 switches - using trait pattern from HEAD async_add_entities( [ RoborockSwitch( @@ -87,6 +110,17 @@ async def async_setup_entry( ] ) + # A01 switches + async_add_entities( + RoborockSwitchA01( + coordinator, + description, + ) + for coordinator in config_entry.runtime_data.a01 + for description in A01_SWITCH_DESCRIPTIONS + if description.data_protocol in coordinator.request_protocols + ) + class RoborockSwitch(RoborockEntityV1, SwitchEntity): """A class to let you turn functionality on Roborock devices on and off that does need a coordinator.""" @@ -137,3 +171,52 @@ async def async_turn_on(self, **kwargs: Any) -> None: def is_on(self) -> bool | None: """Return True if entity is on.""" return self._trait.is_on + + +class RoborockSwitchA01(RoborockCoordinatedEntityA01, SwitchEntity): + """A class to let you turn functionality on Roborock A01 devices on and off.""" + + entity_description: RoborockSwitchDescriptionA01 + + def __init__( + self, + coordinator: RoborockDataUpdateCoordinatorA01, + description: RoborockSwitchDescriptionA01, + ) -> None: + """Initialize the entity.""" + self.entity_description = description + super().__init__(f"{description.key}_{coordinator.duid_slug}", coordinator) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the switch.""" + try: + await self.coordinator.api.set_value( # type: ignore[attr-defined] + self.entity_description.data_protocol, 0 + ) + await self.coordinator.async_request_refresh() + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="update_options_failed", + ) from err + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the switch.""" + try: + await self.coordinator.api.set_value( # type: ignore[attr-defined] + self.entity_description.data_protocol, 1 + ) + await self.coordinator.async_request_refresh() + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="update_options_failed", + ) from err + + @property + def is_on(self) -> bool | None: + """Return True if entity is on.""" + status = self.coordinator.data.get(self.entity_description.data_protocol) + if status is None: + return None + return bool(status) diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 361f9dcf79d2e8..e0ed13b631ab1e 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -4,25 +4,36 @@ from typing import Any from roborock.data import RoborockStateCode, SCWindMapping, WorkStatusMapping +from roborock.data.b01_q10.b01_q10_code_mappings import ( + B01_Q10_DP, + YXDeviceState, + YXFanLevel, +) from roborock.exceptions import RoborockException from roborock.roborock_typing import RoborockCommand from homeassistant.components.vacuum import ( + Segment, StateVacuumEntity, VacuumActivity, VacuumEntityFeature, ) -from homeassistant.core import HomeAssistant, ServiceResponse -from homeassistant.exceptions import HomeAssistantError +from homeassistant.core import HomeAssistant, ServiceResponse, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ( RoborockB01Q7UpdateCoordinator, + RoborockB01Q10UpdateCoordinator, RoborockConfigEntry, RoborockDataUpdateCoordinator, ) -from .entity import RoborockCoordinatedEntityB01, RoborockCoordinatedEntityV1 +from .entity import ( + RoborockCoordinatedEntityB01Q7, + RoborockCoordinatedEntityB01Q10, + RoborockCoordinatedEntityV1, +) _LOGGER = logging.getLogger(__name__) @@ -68,6 +79,26 @@ WorkStatusMapping.MOP_AIRDRYING: VacuumActivity.DOCKED, } +Q10_STATE_CODE_TO_STATE = { + YXDeviceState.SLEEP_STATE: VacuumActivity.IDLE, + YXDeviceState.STANDBY_STATE: VacuumActivity.IDLE, + YXDeviceState.CLEANING_STATE: VacuumActivity.CLEANING, + YXDeviceState.TO_CHARGE_STATE: VacuumActivity.RETURNING, + YXDeviceState.REMOTEING_STATE: VacuumActivity.CLEANING, + YXDeviceState.CHARGING_STATE: VacuumActivity.DOCKED, + YXDeviceState.PAUSE_STATE: VacuumActivity.PAUSED, + YXDeviceState.FAULT_STATE: VacuumActivity.ERROR, + YXDeviceState.UPGRADE_STATE: VacuumActivity.DOCKED, + YXDeviceState.DUSTING: VacuumActivity.DOCKED, + YXDeviceState.CREATING_MAP_STATE: VacuumActivity.CLEANING, + YXDeviceState.RE_LOCATION_STATE: VacuumActivity.CLEANING, + YXDeviceState.ROBOT_SWEEPING: VacuumActivity.CLEANING, + YXDeviceState.ROBOT_MOPING: VacuumActivity.CLEANING, + YXDeviceState.ROBOT_SWEEP_AND_MOPING: VacuumActivity.CLEANING, + YXDeviceState.ROBOT_TRANSITIONING: VacuumActivity.CLEANING, + YXDeviceState.ROBOT_WAIT_CHARGE: VacuumActivity.DOCKED, +} + PARALLEL_UPDATES = 0 @@ -82,15 +113,17 @@ async def async_setup_entry( ) async_add_entities( RoborockQ7Vacuum(coordinator) - for coordinator in config_entry.runtime_data.b01 - if isinstance(coordinator, RoborockB01Q7UpdateCoordinator) + for coordinator in config_entry.runtime_data.b01_q7 + ) + async_add_entities( + RoborockQ10Vacuum(coordinator) + for coordinator in config_entry.runtime_data.b01_q10 ) class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity): """General Representation of a Roborock vacuum.""" - _attr_icon = "mdi:robot-vacuum" _attr_supported_features = ( VacuumEntityFeature.PAUSE | VacuumEntityFeature.STOP @@ -101,6 +134,7 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity): | VacuumEntityFeature.CLEAN_SPOT | VacuumEntityFeature.STATE | VacuumEntityFeature.START + | VacuumEntityFeature.CLEAN_AREA ) _attr_translation_key = DOMAIN _attr_name = None @@ -116,11 +150,33 @@ def __init__( coordinator.duid_slug, coordinator, ) + self._home_trait = coordinator.properties_api.home + self._maps_trait = coordinator.properties_api.maps + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator. + + Creates a repair issue when the vacuum reports different segments than + what was available when the area mapping was last configured. + """ + super()._handle_coordinator_update() + last_seen = self.last_seen_segments + if last_seen is None: + # No area mapping has been configured yet; nothing to check. + return + current_ids = { + f"{map_flag}_{room.segment_id}" + for map_flag, map_info in (self._home_trait.home_map_info or {}).items() + for room in map_info.rooms + } + if current_ids != {seg.id for seg in last_seen}: + self.async_create_segments_issue() @property def fan_speed_list(self) -> list[str]: """Get the list of available fan speeds.""" - return self._device_status.fan_power_options + return [mode.value for mode in self._device_status.fan_speed_options] @property def activity(self) -> VacuumActivity | None: @@ -131,7 +187,7 @@ def activity(self) -> VacuumActivity | None: @property def fan_speed(self) -> str | None: """Return the fan speed of the vacuum cleaner.""" - return self._device_status.fan_power_name + return self._device_status.fan_speed_name async def async_start(self) -> None: """Start the vacuum.""" @@ -170,13 +226,53 @@ async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set vacuum fan speed.""" await self.send( RoborockCommand.SET_CUSTOM_MODE, - [self._device_status.get_fan_speed_code(fan_speed)], + [ + {v: k for k, v in self._device_status.fan_speed_mapping.items()}[ + fan_speed + ] + ], ) async def async_set_vacuum_goto_position(self, x: int, y: int) -> None: """Send vacuum to a specific target point.""" await self.send(RoborockCommand.APP_GOTO_TARGET, [x, y]) + async def async_get_segments(self) -> list[Segment]: + """Get the segments that can be cleaned.""" + home_map_info = self._home_trait.home_map_info + if not home_map_info: + return [] + return [ + Segment( + id=f"{map_flag}_{room.segment_id}", + name=room.name, + group=map_info.name, + ) + for map_flag, map_info in home_map_info.items() + for room in map_info.rooms + ] + + async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None: + """Clean the specified segments.""" + parsed: list[tuple[int, int]] = [] + for seg_id in segment_ids: + map_flag_str, room_id_str = seg_id.split("_", maxsplit=1) + parsed.append((int(map_flag_str), int(room_id_str))) + + # Segments from other maps are silently ignored; only segments + # belonging to the currently active map are cleaned. + current_map = self._maps_trait.current_map + current_map_segments = [ + seg_id for map_flag, seg_id in parsed if map_flag == current_map + ] + if not current_map_segments: + return + + await self.send( + RoborockCommand.APP_SEGMENT_CLEAN, + [{"segments": current_map_segments}], + ) + async def async_send_command( self, command: str, @@ -232,10 +328,9 @@ async def get_vacuum_current_position(self) -> ServiceResponse: } -class RoborockQ7Vacuum(RoborockCoordinatedEntityB01, StateVacuumEntity): +class RoborockQ7Vacuum(RoborockCoordinatedEntityB01Q7, StateVacuumEntity): """General Representation of a Roborock vacuum.""" - _attr_icon = "mdi:robot-vacuum" _attr_supported_features = ( VacuumEntityFeature.PAUSE | VacuumEntityFeature.STOP @@ -256,7 +351,7 @@ def __init__( ) -> None: """Initialize a vacuum.""" StateVacuumEntity.__init__(self) - RoborockCoordinatedEntityB01.__init__( + RoborockCoordinatedEntityB01Q7.__init__( self, coordinator.duid_slug, coordinator, @@ -376,3 +471,174 @@ async def async_send_command( "command": command, }, ) from err + + +class RoborockQ10Vacuum(RoborockCoordinatedEntityB01Q10, StateVacuumEntity): + """Representation of a Roborock Q10 vacuum.""" + + _attr_supported_features = ( + VacuumEntityFeature.PAUSE + | VacuumEntityFeature.STOP + | VacuumEntityFeature.RETURN_HOME + | VacuumEntityFeature.FAN_SPEED + | VacuumEntityFeature.SEND_COMMAND + | VacuumEntityFeature.LOCATE + | VacuumEntityFeature.STATE + | VacuumEntityFeature.START + ) + _attr_translation_key = DOMAIN + _attr_name = None + _attr_fan_speed_list = [ + fan_level.value for fan_level in YXFanLevel if fan_level != YXFanLevel.UNKNOWN + ] + + def __init__( + self, + coordinator: RoborockB01Q10UpdateCoordinator, + ) -> None: + """Initialize a vacuum.""" + StateVacuumEntity.__init__(self) + RoborockCoordinatedEntityB01Q10.__init__( + self, + 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 activity(self) -> VacuumActivity | None: + """Return the status of the vacuum cleaner.""" + if self.coordinator.api.status.status is not None: + return Q10_STATE_CODE_TO_STATE.get(self.coordinator.api.status.status) + return None + + @property + def fan_speed(self) -> str | None: + """Return the fan speed of the vacuum cleaner.""" + if (fan_level := self.coordinator.api.status.fan_level) is not None: + return fan_level.value + return None + + async def async_start(self) -> None: + """Start the vacuum.""" + try: + await self.coordinator.api.vacuum.start_clean() + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={ + "command": "start_clean", + }, + ) from err + + async def async_pause(self) -> None: + """Pause the vacuum.""" + try: + await self.coordinator.api.vacuum.pause_clean() + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={ + "command": "pause_clean", + }, + ) from err + + async def async_stop(self, **kwargs: Any) -> None: + """Stop the vacuum.""" + try: + await self.coordinator.api.vacuum.stop_clean() + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={ + "command": "stop_clean", + }, + ) from err + + async def async_return_to_base(self, **kwargs: Any) -> None: + """Send vacuum back to base.""" + try: + await self.coordinator.api.vacuum.return_to_dock() + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={ + "command": "return_to_dock", + }, + ) from err + + async def async_locate(self, **kwargs: Any) -> None: + """Locate vacuum.""" + try: + await self.coordinator.api.command.send(B01_Q10_DP.SEEK) + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={ + "command": "find_me", + }, + ) from err + + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: + """Set vacuum fan speed.""" + try: + fan_level = YXFanLevel.from_value(fan_speed) + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_fan_speed", + translation_placeholders={ + "fan_speed": fan_speed, + }, + ) from err + try: + await self.coordinator.api.vacuum.set_fan_level(fan_level) + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={ + "command": "set_fan_speed", + }, + ) from err + + async def async_send_command( + self, + command: str, + params: dict[str, Any] | list[Any] | None = None, + **kwargs: Any, + ) -> None: + """Send a command to a vacuum cleaner. + + The command string can be an enum name (e.g. "SEEK"), a DP string + value (e.g. "dpSeek"), or an integer code (e.g. "11"). + """ + if (dp_command := B01_Q10_DP.from_any_optional(command)) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_command", + translation_placeholders={ + "command": command, + }, + ) + try: + await self.coordinator.api.command.send(dp_command, params=params) + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={ + "command": command, + }, + ) from err diff --git a/homeassistant/components/roku/browse_media.py b/homeassistant/components/roku/browse_media.py index 5387963727d927..80fcd0c8901e4c 100644 --- a/homeassistant/components/roku/browse_media.py +++ b/homeassistant/components/roku/browse_media.py @@ -131,7 +131,7 @@ async def root_payload( ) for child in children: - child.thumbnail = "https://brands.home-assistant.io/_/roku/logo.png" + child.thumbnail = "/api/brands/integration/roku/logo.png" try: browse_item = await media_source.async_browse_media(hass, None) diff --git a/homeassistant/components/roomba/binary_sensor.py b/homeassistant/components/roomba/binary_sensor.py index d50535c885a3d5..ba362914b6d4f6 100644 --- a/homeassistant/components/roomba/binary_sensor.py +++ b/homeassistant/components/roomba/binary_sensor.py @@ -37,7 +37,7 @@ def unique_id(self): return f"bin_{self._blid}" @property - def is_on(self): + def is_on(self) -> bool: """Return the state of the sensor.""" return roomba_reported_state(self.vacuum).get("bin", {}).get("full", False) diff --git a/homeassistant/components/roomba/vacuum.py b/homeassistant/components/roomba/vacuum.py index d955c7a7ecf747..6abc1d52398c48 100644 --- a/homeassistant/components/roomba/vacuum.py +++ b/homeassistant/components/roomba/vacuum.py @@ -125,7 +125,7 @@ def __init__(self, roomba, blid) -> None: self._cap_position = self.vacuum_state.get("cap", {}).get("pose") == 1 @property - def activity(self): + def activity(self) -> VacuumActivity: """Return the state of the vacuum cleaner.""" clean_mission_status = self.vacuum_state.get("cleanMissionStatus", {}) cycle = clean_mission_status.get("cycle") @@ -213,7 +213,7 @@ async def async_start(self) -> None: else: await self.hass.async_add_executor_job(self.vacuum.send_command, "start") - async def async_stop(self, **kwargs): + async def async_stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" await self.hass.async_add_executor_job(self.vacuum.send_command, "stop") @@ -221,7 +221,7 @@ async def async_pause(self) -> None: """Pause the cleaning cycle.""" await self.hass.async_add_executor_job(self.vacuum.send_command, "pause") - async def async_return_to_base(self, **kwargs): + async def async_return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" if self.state == VacuumActivity.CLEANING: await self.async_pause() @@ -231,11 +231,16 @@ async def async_return_to_base(self, **kwargs): await asyncio.sleep(1) await self.hass.async_add_executor_job(self.vacuum.send_command, "dock") - async def async_locate(self, **kwargs): + async def async_locate(self, **kwargs: Any) -> None: """Located vacuum.""" await self.hass.async_add_executor_job(self.vacuum.send_command, "find") - async def async_send_command(self, command, params=None, **kwargs): + async def async_send_command( + self, + command: str, + params: dict[str, Any] | list[Any] | None = None, + **kwargs: Any, + ) -> None: """Send raw command.""" _LOGGER.debug("async_send_command %s (%s), %s", command, params, kwargs) await self.hass.async_add_executor_job( @@ -270,7 +275,7 @@ class RoombaVacuumCarpetBoost(RoombaVacuum): _attr_supported_features = SUPPORT_ROOMBA_CARPET_BOOST @property - def fan_speed(self): + def fan_speed(self) -> str | None: """Return the fan speed of the vacuum cleaner.""" fan_speed = None carpet_boost = self.vacuum_state.get("carpetBoost") @@ -284,7 +289,7 @@ def fan_speed(self): fan_speed = FAN_SPEED_ECO return fan_speed - async def async_set_fan_speed(self, fan_speed, **kwargs): + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set fan speed.""" if fan_speed.capitalize() in FAN_SPEEDS: fan_speed = fan_speed.capitalize() @@ -329,7 +334,7 @@ def __init__(self, roomba, blid) -> None: ] @property - def fan_speed(self): + def fan_speed(self) -> str: """Return the fan speed of the vacuum cleaner.""" # Mopping behavior and spray amount as fan speed rank_overlap = self.vacuum_state.get("rankOverlap", {}) @@ -345,7 +350,7 @@ def fan_speed(self): pad_wetness_value = pad_wetness.get("disposable") return f"{behavior}-{pad_wetness_value}" - async def async_set_fan_speed(self, fan_speed, **kwargs): + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set fan speed.""" try: split = fan_speed.split("-", 1) diff --git a/homeassistant/components/route_b_smart_meter/coordinator.py b/homeassistant/components/route_b_smart_meter/coordinator.py index 7cfa2810b5b0f1..9ca9708791fdbb 100644 --- a/homeassistant/components/route_b_smart_meter/coordinator.py +++ b/homeassistant/components/route_b_smart_meter/coordinator.py @@ -2,6 +2,7 @@ from dataclasses import dataclass import logging +import time from momonga import Momonga, MomongaError @@ -28,9 +29,20 @@ class BRouteData: type BRouteConfigEntry = ConfigEntry[BRouteUpdateCoordinator] +@dataclass +class BRouteDeviceInfo: + """Static device information fetched once at setup.""" + + serial_number: str | None = None + manufacturer_code: str | None = None + echonet_version: str | None = None + + class BRouteUpdateCoordinator(DataUpdateCoordinator[BRouteData]): """The B Route update coordinator.""" + device_info_data: BRouteDeviceInfo + def __init__( self, hass: HomeAssistant, @@ -40,9 +52,9 @@ def __init__( self.device = entry.data[CONF_DEVICE] self.bid = entry.data[CONF_ID] - password = entry.data[CONF_PASSWORD] + self._password = entry.data[CONF_PASSWORD] - self.api = Momonga(dev=self.device, rbid=self.bid, pwd=password) + self.api = Momonga(dev=self.device, rbid=self.bid, pwd=self._password) super().__init__( hass, @@ -52,10 +64,34 @@ def __init__( update_interval=DEFAULT_SCAN_INTERVAL, ) + self.device_info_data = BRouteDeviceInfo() + async def _async_setup(self) -> None: - await self.hass.async_add_executor_job( - self.api.open, - ) + def fetch() -> None: + self.api.open() + self._fetch_device_info() + + await self.hass.async_add_executor_job(fetch) + + def _fetch_device_info(self) -> None: + """Fetch static device information from the smart meter.""" + try: + self.device_info_data.serial_number = self.api.get_serial_number() + except MomongaError: + _LOGGER.debug("Failed to fetch serial number", exc_info=True) + + time.sleep(self.api.internal_xmit_interval) + try: + raw = self.api.get_manufacturer_code() + self.device_info_data.manufacturer_code = raw.hex().upper() + except MomongaError: + _LOGGER.debug("Failed to fetch manufacturer code", exc_info=True) + + time.sleep(self.api.internal_xmit_interval) + try: + self.device_info_data.echonet_version = self.api.get_standard_version() + except MomongaError: + _LOGGER.debug("Failed to fetch ECHONET Lite version", exc_info=True) def _get_data(self) -> BRouteData: """Get the data from API.""" diff --git a/homeassistant/components/route_b_smart_meter/sensor.py b/homeassistant/components/route_b_smart_meter/sensor.py index c8034528f5ac83..c85a633f29c4f8 100644 --- a/homeassistant/components/route_b_smart_meter/sensor.py +++ b/homeassistant/components/route_b_smart_meter/sensor.py @@ -2,6 +2,7 @@ from collections.abc import Callable from dataclasses import dataclass +from typing import Literal from homeassistant.components.sensor import ( SensorDeviceClass, @@ -69,6 +70,27 @@ class SensorEntityDescriptionWithValueAccessor(SensorEntityDescription): ), ) +_DEVICE_INFO_MAPPING: dict[ + Literal["manufacturer", "serial_number", "sw_version"], + Callable[[BRouteUpdateCoordinator], str | None], +] = { + "manufacturer": lambda coordinator: coordinator.device_info_data.manufacturer_code, + "serial_number": lambda coordinator: coordinator.device_info_data.serial_number, + "sw_version": lambda coordinator: coordinator.device_info_data.echonet_version, +} + + +def _build_device_info(coordinator: BRouteUpdateCoordinator) -> DeviceInfo: + """Build device information from coordinator data.""" + device = DeviceInfo( + identifiers={(DOMAIN, coordinator.bid)}, + name=f"Route B Smart Meter {coordinator.bid}", + ) + for key, fn in _DEVICE_INFO_MAPPING.items(): + if (value := fn(coordinator)) is not None: + device[key] = value + return device + async def async_setup_entry( hass: HomeAssistant, @@ -98,10 +120,7 @@ def __init__( super().__init__(coordinator) self.entity_description: SensorEntityDescriptionWithValueAccessor = description self._attr_unique_id = f"{coordinator.bid}_{description.key}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.bid)}, - name=f"Route B Smart Meter {coordinator.bid}", - ) + self._attr_device_info = _build_device_info(coordinator) @property def native_value(self) -> StateType: diff --git a/homeassistant/components/russound_rio/media_browser.py b/homeassistant/components/russound_rio/media_browser.py index 7e5ca741f90922..49cd8dae9c47bc 100644 --- a/homeassistant/components/russound_rio/media_browser.py +++ b/homeassistant/components/russound_rio/media_browser.py @@ -35,7 +35,7 @@ async def _root_payload( media_class=MediaClass.DIRECTORY, media_content_id="", media_content_type="presets", - thumbnail="https://brands.home-assistant.io/_/russound_rio/logo.png", + thumbnail="/api/brands/integration/russound_rio/logo.png", can_play=False, can_expand=True, ) diff --git a/homeassistant/components/satel_integra/__init__.py b/homeassistant/components/satel_integra/__init__.py index c2fcb6fe62c4f1..b81cf9b8e86b61 100644 --- a/homeassistant/components/satel_integra/__init__.py +++ b/homeassistant/components/satel_integra/__init__.py @@ -2,208 +2,60 @@ import logging -from satel_integra.satel_integra import AsyncSatel -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import ( - CONF_CODE, - CONF_HOST, - CONF_NAME, - CONF_PORT, - EVENT_HOMEASSISTANT_STOP, - Platform, -) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback -from homeassistant.data_entry_flow import FlowResultType -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import ( - config_validation as cv, - device_registry as dr, - issue_registry as ir, -) -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries -from homeassistant.helpers.typing import ConfigType +from .client import SatelClient from .const import ( - CONF_ARM_HOME_MODE, - CONF_DEVICE_PARTITIONS, CONF_OUTPUT_NUMBER, - CONF_OUTPUTS, CONF_PARTITION_NUMBER, CONF_SWITCHABLE_OUTPUT_NUMBER, - CONF_SWITCHABLE_OUTPUTS, CONF_ZONE_NUMBER, - CONF_ZONE_TYPE, - CONF_ZONES, - DEFAULT_CONF_ARM_HOME_MODE, - DEFAULT_PORT, - DEFAULT_ZONE_TYPE, DOMAIN, - SIGNAL_OUTPUTS_UPDATED, - SIGNAL_PANEL_MESSAGE, - SIGNAL_ZONES_UPDATED, SUBENTRY_TYPE_OUTPUT, SUBENTRY_TYPE_PARTITION, SUBENTRY_TYPE_SWITCHABLE_OUTPUT, SUBENTRY_TYPE_ZONE, - ZONES, +) +from .coordinator import ( SatelConfigEntry, + SatelIntegraData, + SatelIntegraOutputsCoordinator, + SatelIntegraPartitionsCoordinator, + SatelIntegraZonesCoordinator, ) _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.SWITCH] - -ZONE_SCHEMA = vol.Schema( - { - vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_ZONE_TYPE, default=DEFAULT_ZONE_TYPE): cv.string, - } -) -EDITABLE_OUTPUT_SCHEMA = vol.Schema({vol.Required(CONF_NAME): cv.string}) -PARTITION_SCHEMA = vol.Schema( - { - vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_ARM_HOME_MODE, default=DEFAULT_CONF_ARM_HOME_MODE): vol.In( - [1, 2, 3] - ), - } -) - - -def is_alarm_code_necessary(value): - """Check if alarm code must be configured.""" - if value.get(CONF_SWITCHABLE_OUTPUTS) and CONF_CODE not in value: - raise vol.Invalid("You need to specify alarm code to use switchable_outputs") - - return value - - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.All( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_CODE): cv.string, - vol.Optional(CONF_DEVICE_PARTITIONS, default={}): { - vol.Coerce(int): PARTITION_SCHEMA - }, - vol.Optional(CONF_ZONES, default={}): {vol.Coerce(int): ZONE_SCHEMA}, - vol.Optional(CONF_OUTPUTS, default={}): {vol.Coerce(int): ZONE_SCHEMA}, - vol.Optional(CONF_SWITCHABLE_OUTPUTS, default={}): { - vol.Coerce(int): EDITABLE_OUTPUT_SCHEMA - }, - }, - is_alarm_code_necessary, - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: - """Set up Satel Integra from YAML.""" - - if config := hass_config.get(DOMAIN): - hass.async_create_task(_async_import(hass, config)) - - return True - - -async def _async_import(hass: HomeAssistant, config: ConfigType) -> None: - """Process YAML import.""" - - if not hass.config_entries.async_entries(DOMAIN): - # Start import flow - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - - if result.get("type") == FlowResultType.ABORT: - ir.async_create_issue( - hass, - DOMAIN, - "deprecated_yaml_import_issue_cannot_connect", - breaks_in_ha_version="2026.4.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_yaml_import_issue_cannot_connect", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Satel Integra", - }, - ) - return - - ir.async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2026.4.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Satel Integra", - }, - ) +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry(hass: HomeAssistant, entry: SatelConfigEntry) -> bool: """Set up Satel Integra from a config entry.""" - host = entry.data[CONF_HOST] - port = entry.data[CONF_PORT] - - # Make sure we initialize the Satel controller with the configured entries to monitor - partitions = [ - subentry.data[CONF_PARTITION_NUMBER] - for subentry in entry.subentries.values() - if subentry.subentry_type == SUBENTRY_TYPE_PARTITION - ] - - zones = [ - subentry.data[CONF_ZONE_NUMBER] - for subentry in entry.subentries.values() - if subentry.subentry_type == SUBENTRY_TYPE_ZONE - ] - - outputs = [ - subentry.data[CONF_OUTPUT_NUMBER] - for subentry in entry.subentries.values() - if subentry.subentry_type == SUBENTRY_TYPE_OUTPUT - ] - - switchable_outputs = [ - subentry.data[CONF_SWITCHABLE_OUTPUT_NUMBER] - for subentry in entry.subentries.values() - if subentry.subentry_type == SUBENTRY_TYPE_SWITCHABLE_OUTPUT - ] - - monitored_outputs = outputs + switchable_outputs + client = SatelClient(hass, entry) - controller = AsyncSatel(host, port, hass.loop, zones, monitored_outputs, partitions) + coordinator_zones = SatelIntegraZonesCoordinator(hass, entry, client) + coordinator_outputs = SatelIntegraOutputsCoordinator(hass, entry, client) + coordinator_partitions = SatelIntegraPartitionsCoordinator(hass, entry, client) - result = await controller.connect() - - if not result: - raise ConfigEntryNotReady("Controller failed to connect") - - entry.runtime_data = controller - - @callback - def _close(*_): - controller.close() + await client.async_connect( + coordinator_zones.zones_update_callback, + coordinator_outputs.outputs_update_callback, + coordinator_partitions.partitions_update_callback, + ) + entry.runtime_data = SatelIntegraData( + client=client, + coordinator_zones=coordinator_zones, + coordinator_outputs=coordinator_outputs, + coordinator_partitions=coordinator_partitions, + ) entry.async_on_unload(entry.add_update_listener(update_listener)) - entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close)) device_registry = dr.async_get(hass) device_registry.async_get_or_create( @@ -214,33 +66,6 @@ def _close(*_): await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - @callback - def alarm_status_update_callback(): - """Send status update received from alarm to Home Assistant.""" - _LOGGER.debug("Sending request to update panel state") - async_dispatcher_send(hass, SIGNAL_PANEL_MESSAGE) - - @callback - def zones_update_callback(status): - """Update zone objects as per notification from the alarm.""" - _LOGGER.debug("Zones callback, status: %s", status) - async_dispatcher_send(hass, SIGNAL_ZONES_UPDATED, status[ZONES]) - - @callback - def outputs_update_callback(status): - """Update zone objects as per notification from the alarm.""" - _LOGGER.debug("Outputs updated callback , status: %s", status) - async_dispatcher_send(hass, SIGNAL_OUTPUTS_UPDATED, status["outputs"]) - - # Create a task instead of adding a tracking job, since this task will - # run until the connection to satel_integra is closed. - hass.loop.create_task(controller.keep_alive()) - hass.loop.create_task( - controller.monitor_status( - alarm_status_update_callback, zones_update_callback, outputs_update_callback - ) - ) - return True @@ -248,8 +73,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: SatelConfigEntry) -> bo """Unloading the Satel platforms.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - controller = entry.runtime_data - controller.close() + runtime_data = entry.runtime_data + runtime_data.client.close() return unload_ok diff --git a/homeassistant/components/satel_integra/alarm_control_panel.py b/homeassistant/components/satel_integra/alarm_control_panel.py index d17c7d995b4d23..549ddcca9a2c3a 100644 --- a/homeassistant/components/satel_integra/alarm_control_panel.py +++ b/homeassistant/components/satel_integra/alarm_control_panel.py @@ -5,7 +5,7 @@ import asyncio import logging -from satel_integra.satel_integra import AlarmState, AsyncSatel +from satel_integra.satel_integra import AlarmState from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, @@ -15,16 +15,10 @@ ) from homeassistant.config_entries import ConfigSubentry 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_ARM_HOME_MODE, - CONF_PARTITION_NUMBER, - SIGNAL_PANEL_MESSAGE, - SUBENTRY_TYPE_PARTITION, - SatelConfigEntry, -) +from .const import CONF_ARM_HOME_MODE, CONF_PARTITION_NUMBER, SUBENTRY_TYPE_PARTITION +from .coordinator import SatelConfigEntry, SatelIntegraPartitionsCoordinator from .entity import SatelIntegraEntity ALARM_STATE_MAP = { @@ -49,7 +43,7 @@ async def async_setup_entry( ) -> None: """Set up for Satel Integra alarm panels.""" - controller = config_entry.runtime_data + runtime_data = config_entry.runtime_data partition_subentries = filter( lambda entry: entry.subentry_type == SUBENTRY_TYPE_PARTITION, @@ -63,7 +57,7 @@ async def async_setup_entry( async_add_entities( [ SatelIntegraAlarmPanel( - controller, + runtime_data.coordinator_partitions, config_entry.entry_id, subentry, partition_num, @@ -74,8 +68,10 @@ async def async_setup_entry( ) -class SatelIntegraAlarmPanel(SatelIntegraEntity, AlarmControlPanelEntity): - """Representation of an AlarmDecoder-based alarm panel.""" +class SatelIntegraAlarmPanel( + SatelIntegraEntity[SatelIntegraPartitionsCoordinator], AlarmControlPanelEntity +): + """Representation of a Satel Integra-based alarm panel.""" _attr_code_format = CodeFormat.NUMBER _attr_supported_features = ( @@ -85,7 +81,7 @@ class SatelIntegraAlarmPanel(SatelIntegraEntity, AlarmControlPanelEntity): def __init__( self, - controller: AsyncSatel, + coordinator: SatelIntegraPartitionsCoordinator, config_entry_id: str, subentry: ConfigSubentry, device_number: int, @@ -93,7 +89,7 @@ def __init__( ) -> None: """Initialize the alarm panel.""" super().__init__( - controller, + coordinator, config_entry_id, subentry, device_number, @@ -101,36 +97,25 @@ def __init__( self._arm_home_mode = arm_home_mode - async def async_added_to_hass(self) -> None: - """Update alarm status and register callbacks for future updates.""" self._attr_alarm_state = self._read_alarm_state() - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_PANEL_MESSAGE, self._update_alarm_status - ) - ) - @callback - def _update_alarm_status(self) -> None: - """Handle alarm status update.""" - state = self._read_alarm_state() - - if state != self._attr_alarm_state: - self._attr_alarm_state = state - self.async_write_ha_state() + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_alarm_state = self._read_alarm_state() + self.async_write_ha_state() def _read_alarm_state(self) -> AlarmControlPanelState | None: """Read current status of the alarm and translate it into HA status.""" - if not self._satel.connected: + 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._satel.partition_states - and self._device_number in self._satel.partition_states[satel_state] + satel_state in self.coordinator.data + and self._device_number in self.coordinator.data[satel_state] ): return ha_state @@ -146,21 +131,21 @@ async def async_alarm_disarm(self, code: str | None = None) -> None: self._attr_alarm_state == AlarmControlPanelState.TRIGGERED ) - await self._satel.disarm(code, [self._device_number]) + await self._controller.disarm(code, [self._device_number]) if clear_alarm_necessary: # Wait 1s before clearing the alarm await asyncio.sleep(1) - await self._satel.clear_alarm(code, [self._device_number]) + await self._controller.clear_alarm(code, [self._device_number]) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" if code: - await self._satel.arm(code, [self._device_number]) + await self._controller.arm(code, [self._device_number]) async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" if code: - await self._satel.arm(code, [self._device_number], self._arm_home_mode) + await self._controller.arm(code, [self._device_number], self._arm_home_mode) diff --git a/homeassistant/components/satel_integra/binary_sensor.py b/homeassistant/components/satel_integra/binary_sensor.py index 94e791532cf98e..567fecb132d86f 100644 --- a/homeassistant/components/satel_integra/binary_sensor.py +++ b/homeassistant/components/satel_integra/binary_sensor.py @@ -2,27 +2,22 @@ from __future__ import annotations -from satel_integra.satel_integra import AsyncSatel - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import ConfigSubentry 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_OUTPUT_NUMBER, CONF_ZONE_NUMBER, CONF_ZONE_TYPE, - SIGNAL_OUTPUTS_UPDATED, - SIGNAL_ZONES_UPDATED, SUBENTRY_TYPE_OUTPUT, SUBENTRY_TYPE_ZONE, - SatelConfigEntry, ) +from .coordinator import SatelConfigEntry, SatelIntegraBaseCoordinator from .entity import SatelIntegraEntity @@ -33,7 +28,7 @@ async def async_setup_entry( ) -> None: """Set up the Satel Integra binary sensor devices.""" - controller = config_entry.runtime_data + runtime_data = config_entry.runtime_data zone_subentries = filter( lambda entry: entry.subentry_type == SUBENTRY_TYPE_ZONE, @@ -47,12 +42,11 @@ async def async_setup_entry( async_add_entities( [ SatelIntegraBinarySensor( - controller, + runtime_data.coordinator_zones, config_entry.entry_id, subentry, zone_num, zone_type, - SIGNAL_ZONES_UPDATED, ) ], config_subentry_id=subentry.subentry_id, @@ -70,59 +64,48 @@ async def async_setup_entry( async_add_entities( [ SatelIntegraBinarySensor( - controller, + runtime_data.coordinator_outputs, config_entry.entry_id, subentry, output_num, ouput_type, - SIGNAL_OUTPUTS_UPDATED, ) ], config_subentry_id=subentry.subentry_id, ) -class SatelIntegraBinarySensor(SatelIntegraEntity, BinarySensorEntity): - """Representation of an Satel Integra binary sensor.""" +class SatelIntegraBinarySensor[_CoordinatorT: SatelIntegraBaseCoordinator]( + SatelIntegraEntity[_CoordinatorT], BinarySensorEntity +): + """Base binary sensor for Satel Integra.""" def __init__( self, - controller: AsyncSatel, + coordinator: _CoordinatorT, config_entry_id: str, subentry: ConfigSubentry, device_number: int, device_class: BinarySensorDeviceClass, - react_to_signal: str, ) -> None: """Initialize the binary_sensor.""" super().__init__( - controller, + coordinator, config_entry_id, subentry, device_number, ) self._attr_device_class = device_class - self._react_to_signal = react_to_signal - - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - if self._react_to_signal == SIGNAL_OUTPUTS_UPDATED: - self._attr_is_on = self._device_number in self._satel.violated_outputs - else: - self._attr_is_on = self._device_number in self._satel.violated_zones - - self.async_on_remove( - async_dispatcher_connect( - self.hass, self._react_to_signal, self._devices_updated - ) - ) + + self._attr_is_on = self._get_state_from_coordinator() @callback - def _devices_updated(self, zones: dict[int, int]): - """Update the zone's state, if needed.""" - if self._device_number in zones: - new_state = zones[self._device_number] == 1 - if new_state != self._attr_is_on: - self._attr_is_on = new_state - self.async_write_ha_state() + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_is_on = self._get_state_from_coordinator() + self.async_write_ha_state() + + def _get_state_from_coordinator(self) -> bool | None: + """Method to get binary sensor state from coordinator data.""" + return self.coordinator.data.get(self._device_number) diff --git a/homeassistant/components/satel_integra/client.py b/homeassistant/components/satel_integra/client.py new file mode 100644 index 00000000000000..6950583f17306a --- /dev/null +++ b/homeassistant/components/satel_integra/client.py @@ -0,0 +1,105 @@ +"""Satel Integra client.""" + +from collections.abc import Callable + +from satel_integra.satel_integra import AsyncSatel + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import ( + CONF_OUTPUT_NUMBER, + CONF_PARTITION_NUMBER, + CONF_SWITCHABLE_OUTPUT_NUMBER, + CONF_ZONE_NUMBER, + SUBENTRY_TYPE_OUTPUT, + SUBENTRY_TYPE_PARTITION, + SUBENTRY_TYPE_SWITCHABLE_OUTPUT, + SUBENTRY_TYPE_ZONE, +) + + +class SatelClient: + """Client to connect to Satel Integra.""" + + controller: AsyncSatel + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the client wrapper.""" + self.hass = hass + self.config_entry = entry + + host = entry.data[CONF_HOST] + port = entry.data[CONF_PORT] + + # Make sure we initialize the Satel controller with the configured entries to monitor + partitions = [ + subentry.data[CONF_PARTITION_NUMBER] + for subentry in entry.subentries.values() + if subentry.subentry_type == SUBENTRY_TYPE_PARTITION + ] + + zones = [ + subentry.data[CONF_ZONE_NUMBER] + for subentry in entry.subentries.values() + if subentry.subentry_type == SUBENTRY_TYPE_ZONE + ] + + outputs = [ + subentry.data[CONF_OUTPUT_NUMBER] + for subentry in entry.subentries.values() + if subentry.subentry_type == SUBENTRY_TYPE_OUTPUT + ] + + switchable_outputs = [ + subentry.data[CONF_SWITCHABLE_OUTPUT_NUMBER] + for subentry in entry.subentries.values() + if subentry.subentry_type == SUBENTRY_TYPE_SWITCHABLE_OUTPUT + ] + + monitored_outputs = outputs + switchable_outputs + + self.controller = AsyncSatel( + host, port, hass.loop, zones, monitored_outputs, partitions + ) + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.close) + ) + + async def async_connect( + self, + zones_update_callback: Callable[[dict[str, dict[int, int]]], None], + outputs_update_callback: Callable[[dict[str, dict[int, int]]], None], + partitions_update_callback: Callable[[], None], + ) -> None: + """Start controller connection.""" + result = await self.controller.connect() + if not result: + raise ConfigEntryNotReady("Controller failed to connect") + + self.config_entry.async_create_background_task( + self.hass, + self.controller.keep_alive(), + f"satel_integra.{self.config_entry.entry_id}.keep_alive", + eager_start=False, + ) + + self.config_entry.async_create_background_task( + self.hass, + self.controller.monitor_status( + partitions_update_callback, + zones_update_callback, + outputs_update_callback, + ), + f"satel_integra.{self.config_entry.entry_id}.monitor_status", + eager_start=False, + ) + + @callback + def close(self, *args, **kwargs) -> None: + """Close the connection.""" + + self.controller.close() diff --git a/homeassistant/components/satel_integra/config_flow.py b/homeassistant/components/satel_integra/config_flow.py index 1f015c622a9002..10042a9253cd43 100644 --- a/homeassistant/components/satel_integra/config_flow.py +++ b/homeassistant/components/satel_integra/config_flow.py @@ -13,7 +13,6 @@ ConfigEntry, ConfigFlow, ConfigFlowResult, - ConfigSubentryData, ConfigSubentryFlow, OptionsFlow, SubentryFlowResult, @@ -24,15 +23,11 @@ from .const import ( CONF_ARM_HOME_MODE, - CONF_DEVICE_PARTITIONS, CONF_OUTPUT_NUMBER, - CONF_OUTPUTS, CONF_PARTITION_NUMBER, CONF_SWITCHABLE_OUTPUT_NUMBER, - CONF_SWITCHABLE_OUTPUTS, CONF_ZONE_NUMBER, CONF_ZONE_TYPE, - CONF_ZONES, DEFAULT_CONF_ARM_HOME_MODE, DEFAULT_PORT, DOMAIN, @@ -40,8 +35,8 @@ SUBENTRY_TYPE_PARTITION, SUBENTRY_TYPE_SWITCHABLE_OUTPUT, SUBENTRY_TYPE_ZONE, - SatelConfigEntry, ) +from .coordinator import SatelConfigEntry _LOGGER = logging.getLogger(__package__) @@ -49,7 +44,6 @@ { vol.Required(CONF_HOST): str, vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_CODE): cv.string, } ) @@ -90,6 +84,11 @@ class SatelConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a Satel Integra config flow.""" + def __init__(self) -> None: + """Initialize the config flow.""" + super().__init__() + self.connection_data: dict[str, Any] = {} + VERSION = 2 MINOR_VERSION = 1 @@ -123,116 +122,72 @@ async def async_step_user( if user_input is not None: self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) - valid = await self.test_connection( - user_input[CONF_HOST], user_input[CONF_PORT] - ) - - if valid: - return self.async_create_entry( - title=user_input[CONF_HOST], - data={ - CONF_HOST: user_input[CONF_HOST], - CONF_PORT: user_input[CONF_PORT], - }, - options={CONF_CODE: user_input.get(CONF_CODE)}, - ) + if await self.test_connection(user_input[CONF_HOST], user_input[CONF_PORT]): + self.connection_data = { + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + } + return await self.async_step_code() errors["base"] = "cannot_connect" return self.async_show_form( - step_id="user", data_schema=CONNECTION_SCHEMA, errors=errors + step_id="user", + data_schema=CONNECTION_SCHEMA, + errors=errors, ) - async def async_step_import( - self, import_config: dict[str, Any] + async def async_step_code( + self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle a flow initialized by import.""" + """Handle code configuration.""" + if user_input is not None: + return self.async_create_entry( + title=self.connection_data[CONF_HOST], + data=self.connection_data, + options={CONF_CODE: user_input.get(CONF_CODE)}, + ) - valid = await self.test_connection( - import_config[CONF_HOST], import_config.get(CONF_PORT, DEFAULT_PORT) + return self.async_show_form( + step_id="code", + data_schema=CODE_SCHEMA, ) - if valid: - subentries: list[ConfigSubentryData] = [] - - for partition_number, partition_data in import_config.get( - CONF_DEVICE_PARTITIONS, {} - ).items(): - subentries.append( - { - "subentry_type": SUBENTRY_TYPE_PARTITION, - "title": f"{partition_data[CONF_NAME]} ({partition_number})", - "unique_id": f"{SUBENTRY_TYPE_PARTITION}_{partition_number}", - "data": { - CONF_NAME: partition_data[CONF_NAME], - CONF_ARM_HOME_MODE: partition_data.get( - CONF_ARM_HOME_MODE, DEFAULT_CONF_ARM_HOME_MODE - ), - CONF_PARTITION_NUMBER: partition_number, - }, - } - ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() - for zone_number, zone_data in import_config.get(CONF_ZONES, {}).items(): - subentries.append( - { - "subentry_type": SUBENTRY_TYPE_ZONE, - "title": f"{zone_data[CONF_NAME]} ({zone_number})", - "unique_id": f"{SUBENTRY_TYPE_ZONE}_{zone_number}", - "data": { - CONF_NAME: zone_data[CONF_NAME], - CONF_ZONE_NUMBER: zone_number, - CONF_ZONE_TYPE: zone_data.get( - CONF_ZONE_TYPE, BinarySensorDeviceClass.MOTION - ), - }, - } - ) + if user_input is not None: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) - for output_number, output_data in import_config.get( - CONF_OUTPUTS, {} - ).items(): - subentries.append( - { - "subentry_type": SUBENTRY_TYPE_OUTPUT, - "title": f"{output_data[CONF_NAME]} ({output_number})", - "unique_id": f"{SUBENTRY_TYPE_OUTPUT}_{output_number}", - "data": { - CONF_NAME: output_data[CONF_NAME], - CONF_OUTPUT_NUMBER: output_number, - CONF_ZONE_TYPE: output_data.get( - CONF_ZONE_TYPE, BinarySensorDeviceClass.MOTION - ), - }, - } + if await self.test_connection(user_input[CONF_HOST], user_input[CONF_PORT]): + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={ + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + }, + title=user_input[CONF_HOST], + reload_even_if_entry_is_unchanged=False, ) - for switchable_output_number, switchable_output_data in import_config.get( - CONF_SWITCHABLE_OUTPUTS, {} - ).items(): - subentries.append( - { - "subentry_type": SUBENTRY_TYPE_SWITCHABLE_OUTPUT, - "title": f"{switchable_output_data[CONF_NAME]} ({switchable_output_number})", - "unique_id": f"{SUBENTRY_TYPE_SWITCHABLE_OUTPUT}_{switchable_output_number}", - "data": { - CONF_NAME: switchable_output_data[CONF_NAME], - CONF_SWITCHABLE_OUTPUT_NUMBER: switchable_output_number, - }, - } - ) + errors["base"] = "cannot_connect" - return self.async_create_entry( - title=import_config[CONF_HOST], - data={ - CONF_HOST: import_config[CONF_HOST], - CONF_PORT: import_config.get(CONF_PORT, DEFAULT_PORT), - }, - options={CONF_CODE: import_config.get(CONF_CODE)}, - subentries=subentries, - ) + suggested_values: dict[str, Any] = { + **reconfigure_entry.data, + **(user_input or {}), + } - return self.async_abort(reason="cannot_connect") + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + CONNECTION_SCHEMA, suggested_values + ), + errors=errors, + ) async def test_connection(self, host: str, port: int) -> bool: """Test a connection to the Satel alarm.""" diff --git a/homeassistant/components/satel_integra/const.py b/homeassistant/components/satel_integra/const.py index 822fbe7594b23d..8a2f7bc5239bcd 100644 --- a/homeassistant/components/satel_integra/const.py +++ b/homeassistant/components/satel_integra/const.py @@ -1,12 +1,7 @@ """Constants for the Satel Integra integration.""" -from satel_integra.satel_integra import AsyncSatel - -from homeassistant.config_entries import ConfigEntry - DEFAULT_CONF_ARM_HOME_MODE = 1 DEFAULT_PORT = 7094 -DEFAULT_ZONE_TYPE = "motion" DOMAIN = "satel_integra" @@ -20,19 +15,7 @@ CONF_OUTPUT_NUMBER = "output_number" CONF_SWITCHABLE_OUTPUT_NUMBER = "switchable_output_number" -CONF_DEVICE_PARTITIONS = "partitions" CONF_ARM_HOME_MODE = "arm_home_mode" CONF_ZONE_TYPE = "type" -CONF_ZONES = "zones" -CONF_OUTPUTS = "outputs" -CONF_SWITCHABLE_OUTPUTS = "switchable_outputs" ZONES = "zones" - - -SIGNAL_PANEL_MESSAGE = "satel_integra.panel_message" - -SIGNAL_ZONES_UPDATED = "satel_integra.zones_updated" -SIGNAL_OUTPUTS_UPDATED = "satel_integra.outputs_updated" - -type SatelConfigEntry = ConfigEntry[AsyncSatel] diff --git a/homeassistant/components/satel_integra/coordinator.py b/homeassistant/components/satel_integra/coordinator.py new file mode 100644 index 00000000000000..0805ab94ed5a1c --- /dev/null +++ b/homeassistant/components/satel_integra/coordinator.py @@ -0,0 +1,129 @@ +"""Coordinator for Satel Integra.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging + +from satel_integra.satel_integra import AlarmState + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .client import SatelClient +from .const import ZONES + +_LOGGER = logging.getLogger(__name__) + +PARTITION_UPDATE_DEBOUNCE_DELAY = 0.15 + + +@dataclass +class SatelIntegraData: + """Data for the satel_integra integration.""" + + client: SatelClient + coordinator_zones: SatelIntegraZonesCoordinator + coordinator_outputs: SatelIntegraOutputsCoordinator + coordinator_partitions: SatelIntegraPartitionsCoordinator + + +type SatelConfigEntry = ConfigEntry[SatelIntegraData] + + +class SatelIntegraBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): + """DataUpdateCoordinator base class for Satel Integra.""" + + config_entry: SatelConfigEntry + + def __init__( + self, hass: HomeAssistant, entry: SatelConfigEntry, client: SatelClient + ) -> None: + """Initialize the base coordinator.""" + self.client = client + + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=f"{entry.entry_id} {self.__class__.__name__}", + ) + + +class SatelIntegraZonesCoordinator(SatelIntegraBaseCoordinator[dict[int, bool]]): + """DataUpdateCoordinator to handle zone updates.""" + + def __init__( + self, hass: HomeAssistant, entry: SatelConfigEntry, client: SatelClient + ) -> None: + """Initialize the coordinator.""" + super().__init__(hass, entry, client) + + self.data = {} + + @callback + def zones_update_callback(self, status: dict[str, dict[int, int]]) -> None: + """Update zone objects as per notification from the alarm.""" + _LOGGER.debug("Zones callback, status: %s", status) + + update_data = {zone: value == 1 for zone, value in status[ZONES].items()} + + self.async_set_updated_data(update_data) + + +class SatelIntegraOutputsCoordinator(SatelIntegraBaseCoordinator[dict[int, bool]]): + """DataUpdateCoordinator to handle output updates.""" + + def __init__( + self, hass: HomeAssistant, entry: SatelConfigEntry, client: SatelClient + ) -> None: + """Initialize the coordinator.""" + super().__init__(hass, entry, client) + + self.data = {} + + @callback + def outputs_update_callback(self, status: dict[str, dict[int, int]]) -> None: + """Update output objects as per notification from the alarm.""" + _LOGGER.debug("Outputs callback, status: %s", status) + + update_data = { + output: value == 1 for output, value in status["outputs"].items() + } + + self.async_set_updated_data(update_data) + + +class SatelIntegraPartitionsCoordinator( + SatelIntegraBaseCoordinator[dict[AlarmState, list[int]]] +): + """DataUpdateCoordinator to handle partition state updates.""" + + def __init__( + self, hass: HomeAssistant, entry: SatelConfigEntry, client: SatelClient + ) -> None: + """Initialize the coordinator.""" + super().__init__(hass, entry, client) + + self.data = {} + + self._debouncer = Debouncer( + hass=self.hass, + logger=_LOGGER, + cooldown=PARTITION_UPDATE_DEBOUNCE_DELAY, + immediate=False, + function=callback( + lambda: self.async_set_updated_data( + self.client.controller.partition_states + ) + ), + ) + + @callback + def partitions_update_callback(self) -> None: + """Update partition objects as per notification from the alarm.""" + _LOGGER.debug("Sending request to update panel state") + + self._debouncer.async_schedule_call() diff --git a/homeassistant/components/satel_integra/entity.py b/homeassistant/components/satel_integra/entity.py index 0d18e6348921a1..a37339147189d0 100644 --- a/homeassistant/components/satel_integra/entity.py +++ b/homeassistant/components/satel_integra/entity.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_NAME from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( DOMAIN, @@ -18,6 +18,7 @@ SUBENTRY_TYPE_SWITCHABLE_OUTPUT, SUBENTRY_TYPE_ZONE, ) +from .coordinator import SatelIntegraBaseCoordinator SubentryTypeToEntityType: dict[str, str] = { SUBENTRY_TYPE_PARTITION: "alarm_panel", @@ -27,23 +28,29 @@ } -class SatelIntegraEntity(Entity): +class SatelIntegraEntity[_CoordinatorT: SatelIntegraBaseCoordinator]( + CoordinatorEntity[_CoordinatorT] +): """Defines a base Satel Integra entity.""" _attr_should_poll = False _attr_has_entity_name = True _attr_name = None + _controller: AsyncSatel + def __init__( self, - controller: AsyncSatel, + coordinator: _CoordinatorT, config_entry_id: str, subentry: ConfigSubentry, device_number: int, ) -> None: """Initialize the Satel Integra entity.""" + super().__init__(coordinator) + + self._controller = coordinator.client.controller - self._satel = controller self._device_number = device_number entity_type = SubentryTypeToEntityType[subentry.subentry_type] diff --git a/homeassistant/components/satel_integra/strings.json b/homeassistant/components/satel_integra/strings.json index 0440665956b51b..67fe3b94101e47 100644 --- a/homeassistant/components/satel_integra/strings.json +++ b/homeassistant/components/satel_integra/strings.json @@ -5,20 +5,37 @@ }, "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%]" }, "step": { + "code": { + "data": { + "code": "[%key:component::satel_integra::common::code%]" + }, + "data_description": { + "code": "[%key:component::satel_integra::common::code_input_description%]" + } + }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_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": { - "code": "[%key:component::satel_integra::common::code%]", "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, "data_description": { - "code": "[%key:component::satel_integra::common::code_input_description%]", "host": "The IP address of the alarm panel", "port": "The port of the alarm panel" } @@ -162,10 +179,9 @@ } } }, - "issues": { - "deprecated_yaml_import_issue_cannot_connect": { - "description": "Configuring {integration_title} using YAML is being removed but there was an connection error importing your existing configuration.\n\nEnsure connection to {integration_title} works and restart Home Assistant to try again or remove the `{domain}` YAML configuration from your configuration.yaml file and add the {integration_title} integration manually.", - "title": "YAML import failed due to a connection error" + "exceptions": { + "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." } }, "options": { diff --git a/homeassistant/components/satel_integra/switch.py b/homeassistant/components/satel_integra/switch.py index 4ae84e3312e26b..4b33f7d4ef2860 100644 --- a/homeassistant/components/satel_integra/switch.py +++ b/homeassistant/components/satel_integra/switch.py @@ -4,21 +4,19 @@ from typing import Any -from satel_integra.satel_integra import AsyncSatel - from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_CODE from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_SWITCHABLE_OUTPUT_NUMBER, - SIGNAL_OUTPUTS_UPDATED, + DOMAIN, SUBENTRY_TYPE_SWITCHABLE_OUTPUT, - SatelConfigEntry, ) +from .coordinator import SatelConfigEntry, SatelIntegraOutputsCoordinator from .entity import SatelIntegraEntity @@ -29,7 +27,7 @@ async def async_setup_entry( ) -> None: """Set up the Satel Integra switch devices.""" - controller = config_entry.runtime_data + runtime_data = config_entry.runtime_data switchable_output_subentries = filter( lambda entry: entry.subentry_type == SUBENTRY_TYPE_SWITCHABLE_OUTPUT, @@ -42,7 +40,7 @@ async def async_setup_entry( async_add_entities( [ SatelIntegraSwitch( - controller, + runtime_data.coordinator_outputs, config_entry.entry_id, subentry, switchable_output_num, @@ -53,12 +51,14 @@ async def async_setup_entry( ) -class SatelIntegraSwitch(SatelIntegraEntity, SwitchEntity): +class SatelIntegraSwitch( + SatelIntegraEntity[SatelIntegraOutputsCoordinator], SwitchEntity +): """Representation of an Satel Integra switch.""" def __init__( self, - controller: AsyncSatel, + coordinator: SatelIntegraOutputsCoordinator, config_entry_id: str, subentry: ConfigSubentry, device_number: int, @@ -66,7 +66,7 @@ def __init__( ) -> None: """Initialize the switch.""" super().__init__( - controller, + coordinator, config_entry_id, subentry, device_number, @@ -74,33 +74,38 @@ def __init__( self._code = code - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - self._attr_is_on = self._device_number in self._satel.violated_outputs - - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_OUTPUTS_UPDATED, self._devices_updated - ) - ) + self._attr_is_on = self._get_state_from_coordinator() @callback - def _devices_updated(self, outputs: dict[int, int]) -> None: - """Update switch state, if needed.""" - if self._device_number in outputs: - new_state = outputs[self._device_number] == 1 - if new_state != self._attr_is_on: - self._attr_is_on = new_state - self.async_write_ha_state() + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_is_on = self._get_state_from_coordinator() + self.async_write_ha_state() + + def _get_state_from_coordinator(self) -> bool | None: + """Method to get switch state from coordinator data.""" + return self.coordinator.data.get(self._device_number) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - await self._satel.set_output(self._code, self._device_number, True) + if self._code is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="missing_output_access_code", + ) + + await self._controller.set_output(self._code, self._device_number, True) self._attr_is_on = True self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - await self._satel.set_output(self._code, self._device_number, False) + if self._code is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="missing_output_access_code", + ) + + await self._controller.set_output(self._code, self._device_number, False) self._attr_is_on = False self.async_write_ha_state() diff --git a/homeassistant/components/saunum/__init__.py b/homeassistant/components/saunum/__init__.py index f0a1a9161f476f..6248ac8dd721c3 100644 --- a/homeassistant/components/saunum/__init__.py +++ b/homeassistant/components/saunum/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from pysaunum import SaunumClient, SaunumConnectionError +from pysaunum import SaunumClient, SaunumConnectionError, SaunumTimeoutError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform @@ -40,9 +40,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) -> try: client = await SaunumClient.create(host) - except SaunumConnectionError as exc: + except (SaunumConnectionError, SaunumTimeoutError) as exc: raise ConfigEntryNotReady(f"Error connecting to {host}: {exc}") from exc + entry.async_on_unload(client.async_close) + coordinator = LeilSaunaCoordinator(hass, client, entry) await coordinator.async_config_entry_first_refresh() @@ -55,7 +57,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - await entry.runtime_data.client.async_close() - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/saunum/climate.py b/homeassistant/components/saunum/climate.py index 52fb7ed02120b9..f2615593cbc585 100644 --- a/homeassistant/components/saunum/climate.py +++ b/homeassistant/components/saunum/climate.py @@ -3,9 +3,17 @@ from __future__ import annotations import asyncio +from datetime import timedelta from typing import Any -from pysaunum import MAX_TEMPERATURE, MIN_TEMPERATURE, SaunumException +from pysaunum import ( + DEFAULT_DURATION, + DEFAULT_FAN_DURATION, + DEFAULT_TEMPERATURE, + MAX_TEMPERATURE, + MIN_TEMPERATURE, + SaunumException, +) from homeassistant.components.climate import ( FAN_HIGH, @@ -148,7 +156,7 @@ def hvac_action(self) -> HVACAction | None: def preset_mode(self) -> str | None: """Return the current preset mode.""" sauna_type = self.coordinator.data.sauna_type - if sauna_type is not None and sauna_type in self._preset_name_map: + if sauna_type in self._preset_name_map: return self._preset_name_map[sauna_type] return self._preset_name_map[0] @@ -241,9 +249,9 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: async def async_start_session( self, - duration: int = 120, - target_temperature: int = 80, - fan_duration: int = 10, + duration: timedelta = timedelta(minutes=DEFAULT_DURATION), + target_temperature: int = DEFAULT_TEMPERATURE, + fan_duration: timedelta = timedelta(minutes=DEFAULT_FAN_DURATION), ) -> None: """Start a sauna session with custom parameters.""" if self.coordinator.data.door_open: @@ -254,17 +262,20 @@ async def async_start_session( try: # Set all parameters before starting the session - await self.coordinator.client.async_set_sauna_duration(duration) + await self.coordinator.client.async_set_sauna_duration( + int(duration.total_seconds() // 60) + ) await self.coordinator.client.async_set_target_temperature( target_temperature ) - await self.coordinator.client.async_set_fan_duration(fan_duration) + await self.coordinator.client.async_set_fan_duration( + int(fan_duration.total_seconds() // 60) + ) await self.coordinator.client.async_start_session() except SaunumException as err: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="start_session_failed", - translation_placeholders={"error": str(err)}, ) from err await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/saunum/coordinator.py b/homeassistant/components/saunum/coordinator.py index f4d90c24a7c538..540da3b55f4bac 100644 --- a/homeassistant/components/saunum/coordinator.py +++ b/homeassistant/components/saunum/coordinator.py @@ -47,5 +47,4 @@ async def _async_update_data(self) -> SaunumData: raise UpdateFailed( translation_domain=DOMAIN, translation_key="communication_error", - translation_placeholders={"error": str(err)}, ) from err diff --git a/homeassistant/components/saunum/manifest.json b/homeassistant/components/saunum/manifest.json index 65ed36fa79d0bf..d65394d01ae6e3 100644 --- a/homeassistant/components/saunum/manifest.json +++ b/homeassistant/components/saunum/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["pysaunum"], "quality_scale": "platinum", - "requirements": ["pysaunum==0.3.0"] + "requirements": ["pysaunum==0.6.0"] } diff --git a/homeassistant/components/saunum/number.py b/homeassistant/components/saunum/number.py index 0a59127ffd64ea..d6da69deedebf4 100644 --- a/homeassistant/components/saunum/number.py +++ b/homeassistant/components/saunum/number.py @@ -7,6 +7,8 @@ from typing import TYPE_CHECKING from pysaunum import ( + DEFAULT_DURATION, + DEFAULT_FAN_DURATION, MAX_DURATION, MAX_FAN_DURATION, MIN_DURATION, @@ -35,10 +37,6 @@ PARALLEL_UPDATES = 0 -# Default values when device returns None or invalid data -DEFAULT_DURATION_MIN = 120 -DEFAULT_FAN_DURATION_MIN = 15 - @dataclass(frozen=True, kw_only=True) class LeilSaunaNumberEntityDescription(NumberEntityDescription): @@ -59,8 +57,8 @@ class LeilSaunaNumberEntityDescription(NumberEntityDescription): native_step=1, value_fn=lambda data: ( duration - if (duration := data.sauna_duration) is not None and duration > MIN_DURATION - else DEFAULT_DURATION_MIN + if (duration := data.sauna_duration) > MIN_DURATION + else DEFAULT_DURATION ), set_value_fn=lambda client, value: client.async_set_sauna_duration(int(value)), ), @@ -74,8 +72,8 @@ class LeilSaunaNumberEntityDescription(NumberEntityDescription): native_step=1, value_fn=lambda data: ( fan_dur - if (fan_dur := data.fan_duration) is not None and fan_dur > MIN_FAN_DURATION - else DEFAULT_FAN_DURATION_MIN + if (fan_dur := data.fan_duration) > MIN_FAN_DURATION + else DEFAULT_FAN_DURATION ), set_value_fn=lambda client, value: client.async_set_fan_duration(int(value)), ), diff --git a/homeassistant/components/saunum/quality_scale.yaml b/homeassistant/components/saunum/quality_scale.yaml index eb0a70d673268e..fa3f1a67bf07cf 100644 --- a/homeassistant/components/saunum/quality_scale.yaml +++ b/homeassistant/components/saunum/quality_scale.yaml @@ -21,7 +21,7 @@ rules: test-before-setup: done unique-config-entry: done - # Silver tier + # Silver action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done @@ -35,7 +35,7 @@ rules: comment: Modbus TCP does not require authentication. test-coverage: done - # Gold tier + # Gold devices: done diagnostics: done discovery: diff --git a/homeassistant/components/saunum/services.py b/homeassistant/components/saunum/services.py index 0a86da8386dcc2..c45c412e1647d3 100644 --- a/homeassistant/components/saunum/services.py +++ b/homeassistant/components/saunum/services.py @@ -2,7 +2,17 @@ from __future__ import annotations -from pysaunum import MAX_DURATION, MAX_FAN_DURATION, MAX_TEMPERATURE, MIN_TEMPERATURE +from datetime import timedelta + +from pysaunum import ( + DEFAULT_DURATION, + DEFAULT_FAN_DURATION, + DEFAULT_TEMPERATURE, + MAX_DURATION, + MAX_FAN_DURATION, + MAX_TEMPERATURE, + MIN_TEMPERATURE, +) import voluptuous as vol from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN @@ -27,14 +37,26 @@ def async_setup_services(hass: HomeAssistant) -> None: SERVICE_START_SESSION, entity_domain=CLIMATE_DOMAIN, schema={ - vol.Optional(ATTR_DURATION, default=120): vol.All( - cv.positive_int, vol.Range(min=1, max=MAX_DURATION) + vol.Optional( + ATTR_DURATION, default=timedelta(minutes=DEFAULT_DURATION) + ): vol.All( + cv.time_period, + vol.Range( + min=timedelta(minutes=1), + max=timedelta(minutes=MAX_DURATION), + ), ), - vol.Optional(ATTR_TARGET_TEMPERATURE, default=80): vol.All( + vol.Optional(ATTR_TARGET_TEMPERATURE, default=DEFAULT_TEMPERATURE): vol.All( cv.positive_int, vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE) ), - vol.Optional(ATTR_FAN_DURATION, default=10): vol.All( - cv.positive_int, vol.Range(min=1, max=MAX_FAN_DURATION) + vol.Optional( + ATTR_FAN_DURATION, default=timedelta(minutes=DEFAULT_FAN_DURATION) + ): vol.All( + cv.time_period, + vol.Range( + min=timedelta(minutes=1), + max=timedelta(minutes=MAX_FAN_DURATION), + ), ), }, func="async_start_session", diff --git a/homeassistant/components/saunum/strings.json b/homeassistant/components/saunum/strings.json index ca0631337b35e2..4e3645b66991b8 100644 --- a/homeassistant/components/saunum/strings.json +++ b/homeassistant/components/saunum/strings.json @@ -88,7 +88,7 @@ }, "exceptions": { "communication_error": { - "message": "Communication error: {error}" + "message": "Communication error with sauna control unit" }, "door_open": { "message": "Cannot start sauna session when sauna door is open" @@ -130,7 +130,7 @@ "message": "Failed to set temperature to {temperature}" }, "start_session_failed": { - "message": "Failed to start sauna session: {error}" + "message": "Failed to start sauna session" } }, "options": { diff --git a/homeassistant/components/scene/trigger.py b/homeassistant/components/scene/trigger.py index c5537b15812639..15f14f8c38acbd 100644 --- a/homeassistant/components/scene/trigger.py +++ b/homeassistant/components/scene/trigger.py @@ -2,6 +2,7 @@ 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, @@ -14,7 +15,7 @@ class SceneActivatedTrigger(EntityTriggerBase): """Trigger for scene entity activations.""" - _domain = DOMAIN + _domain_specs = {DOMAIN: DomainSpec()} _schema = ENTITY_STATE_TRIGGER_SCHEMA def is_valid_transition(self, from_state: State, to_state: State) -> bool: diff --git a/homeassistant/components/schedule/__init__.py b/homeassistant/components/schedule/__init__.py index 63de3daaf15e18..1e0706621a56d1 100644 --- a/homeassistant/components/schedule/__init__.py +++ b/homeassistant/components/schedule/__init__.py @@ -199,8 +199,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def reload_service_handler(service_call: ServiceCall) -> None: """Reload yaml entities.""" conf = await component.async_prepare_reload(skip_reset=True) - if conf is None: - conf = {DOMAIN: {}} await yaml_collection.async_load( [{CONF_ID: id_, **cfg} for id_, cfg in conf.get(DOMAIN, {}).items()] ) diff --git a/homeassistant/components/schedule/condition.py b/homeassistant/components/schedule/condition.py new file mode 100644 index 00000000000000..e04126c664bf65 --- /dev/null +++ b/homeassistant/components/schedule/condition.py @@ -0,0 +1,17 @@ +"""Provides conditions for schedules.""" + +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_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 schedule conditions.""" + return CONDITIONS diff --git a/homeassistant/components/schedule/conditions.yaml b/homeassistant/components/schedule/conditions.yaml new file mode 100644 index 00000000000000..d9d89d329323ba --- /dev/null +++ b/homeassistant/components/schedule/conditions.yaml @@ -0,0 +1,17 @@ +.condition_common: &condition_common + target: + entity: + domain: schedule + fields: + behavior: + required: true + default: any + selector: + select: + translation_key: condition_behavior + options: + - all + - any + +is_off: *condition_common +is_on: *condition_common diff --git a/homeassistant/components/schedule/icons.json b/homeassistant/components/schedule/icons.json index f7b3ca113709f8..3acb44479de6b1 100644 --- a/homeassistant/components/schedule/icons.json +++ b/homeassistant/components/schedule/icons.json @@ -1,4 +1,12 @@ { + "conditions": { + "is_off": { + "condition": "mdi:calendar-blank" + }, + "is_on": { + "condition": "mdi:calendar-clock" + } + }, "services": { "get_schedule": { "service": "mdi:calendar-export" @@ -6,5 +14,13 @@ "reload": { "service": "mdi:reload" } + }, + "triggers": { + "turned_off": { + "trigger": "mdi:calendar-blank" + }, + "turned_on": { + "trigger": "mdi:calendar-clock" + } } } diff --git a/homeassistant/components/schedule/strings.json b/homeassistant/components/schedule/strings.json index b56d0252e4f378..f416cc149c1c53 100644 --- a/homeassistant/components/schedule/strings.json +++ b/homeassistant/components/schedule/strings.json @@ -1,4 +1,32 @@ { + "common": { + "condition_behavior_description": "How the state should match on the targeted schedules.", + "condition_behavior_name": "Behavior", + "trigger_behavior_description": "The behavior of the targeted schedules to trigger on.", + "trigger_behavior_name": "Behavior" + }, + "conditions": { + "is_off": { + "description": "Tests if one or more schedule blocks are currently not active.", + "fields": { + "behavior": { + "description": "[%key:component::schedule::common::condition_behavior_description%]", + "name": "[%key:component::schedule::common::condition_behavior_name%]" + } + }, + "name": "Schedule is off" + }, + "is_on": { + "description": "Tests if one or more schedule blocks are currently active.", + "fields": { + "behavior": { + "description": "[%key:component::schedule::common::condition_behavior_description%]", + "name": "[%key:component::schedule::common::condition_behavior_name%]" + } + }, + "name": "Schedule is on" + } + }, "entity_component": { "_": { "name": "[%key:component::schedule::title%]", @@ -20,6 +48,21 @@ } } }, + "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.", @@ -30,5 +73,27 @@ "name": "[%key:common::action::reload%]" } }, - "title": "Schedule" + "title": "Schedule", + "triggers": { + "turned_off": { + "description": "Triggers when a schedule block ends.", + "fields": { + "behavior": { + "description": "[%key:component::schedule::common::trigger_behavior_description%]", + "name": "[%key:component::schedule::common::trigger_behavior_name%]" + } + }, + "name": "Schedule block ended" + }, + "turned_on": { + "description": "Triggers when a schedule block starts.", + "fields": { + "behavior": { + "description": "[%key:component::schedule::common::trigger_behavior_description%]", + "name": "[%key:component::schedule::common::trigger_behavior_name%]" + } + }, + "name": "Schedule block started" + } + } } diff --git a/homeassistant/components/schedule/trigger.py b/homeassistant/components/schedule/trigger.py new file mode 100644 index 00000000000000..fb49e963a31398 --- /dev/null +++ b/homeassistant/components/schedule/trigger.py @@ -0,0 +1,43 @@ +"""Provides triggers for schedules.""" + +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.trigger import ( + EntityTransitionTriggerBase, + Trigger, + make_entity_target_state_trigger, +) + +from .const import ATTR_NEXT_EVENT, DOMAIN + + +class ScheduleBackToBackTrigger(EntityTransitionTriggerBase): + """Trigger for back-to-back schedule blocks.""" + + _domain_specs = {DOMAIN: DomainSpec()} + _from_states = {STATE_OFF, STATE_ON} + _to_states = {STATE_ON} + + def is_valid_transition(self, from_state: State, to_state: State) -> bool: + """Check if the origin state matches the expected ones.""" + if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + return False + + from_next_event = from_state.attributes.get(ATTR_NEXT_EVENT) + to_next_event = to_state.attributes.get(ATTR_NEXT_EVENT) + + return ( + from_state.state in self._from_states and from_next_event != to_next_event + ) + + +TRIGGERS: dict[str, type[Trigger]] = { + "turned_on": ScheduleBackToBackTrigger, + "turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for schedules.""" + return TRIGGERS diff --git a/homeassistant/components/schedule/triggers.yaml b/homeassistant/components/schedule/triggers.yaml new file mode 100644 index 00000000000000..e05c515b40133e --- /dev/null +++ b/homeassistant/components/schedule/triggers.yaml @@ -0,0 +1,18 @@ +.trigger_common: &trigger_common + target: + entity: + domain: schedule + fields: + behavior: + required: true + default: any + selector: + select: + options: + - first + - last + - any + translation_key: trigger_behavior + +turned_off: *trigger_common +turned_on: *trigger_common diff --git a/homeassistant/components/schlage/__init__.py b/homeassistant/components/schlage/__init__.py index 509a335aafe8fb..ed995d4aa3d4a7 100644 --- a/homeassistant/components/schlage/__init__.py +++ b/homeassistant/components/schlage/__init__.py @@ -4,11 +4,16 @@ from pycognito.exceptions import WarrantException import pyschlage +import voluptuous as vol +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, SupportsResponse from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import config_validation as cv, service +from homeassistant.helpers.typing import ConfigType +from .const import DOMAIN, SERVICE_ADD_CODE, SERVICE_DELETE_CODE, SERVICE_GET_CODES from .coordinator import SchlageConfigEntry, SchlageDataUpdateCoordinator PLATFORMS: list[Platform] = [ @@ -19,6 +24,46 @@ Platform.SWITCH, ] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Schlage component.""" + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_ADD_CODE, + entity_domain=LOCK_DOMAIN, + schema={ + vol.Required("name"): cv.string, + vol.Required("code"): cv.matches_regex(r"^\d{4,8}$"), + }, + func=SERVICE_ADD_CODE, + ) + + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_DELETE_CODE, + entity_domain=LOCK_DOMAIN, + schema={ + vol.Required("name"): cv.string, + }, + func=SERVICE_DELETE_CODE, + ) + + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_GET_CODES, + entity_domain=LOCK_DOMAIN, + schema=None, + func=SERVICE_GET_CODES, + supports_response=SupportsResponse.ONLY, + ) + + return True + async def async_setup_entry(hass: HomeAssistant, entry: SchlageConfigEntry) -> bool: """Set up Schlage from a config entry.""" diff --git a/homeassistant/components/schlage/const.py b/homeassistant/components/schlage/const.py index 1effd4bb33429f..75033520d3f07e 100644 --- a/homeassistant/components/schlage/const.py +++ b/homeassistant/components/schlage/const.py @@ -7,3 +7,7 @@ LOGGER = logging.getLogger(__package__) MANUFACTURER = "Schlage" UPDATE_INTERVAL = timedelta(seconds=30) + +SERVICE_ADD_CODE = "add_code" +SERVICE_DELETE_CODE = "delete_code" +SERVICE_GET_CODES = "get_codes" diff --git a/homeassistant/components/schlage/icons.json b/homeassistant/components/schlage/icons.json new file mode 100644 index 00000000000000..c231233be5167f --- /dev/null +++ b/homeassistant/components/schlage/icons.json @@ -0,0 +1,13 @@ +{ + "services": { + "add_code": { + "service": "mdi:key-plus" + }, + "delete_code": { + "service": "mdi:key-minus" + }, + "get_codes": { + "service": "mdi:table-key" + } + } +} diff --git a/homeassistant/components/schlage/lock.py b/homeassistant/components/schlage/lock.py index 83abf9214e38e8..739e5a0b1d70c1 100644 --- a/homeassistant/components/schlage/lock.py +++ b/homeassistant/components/schlage/lock.py @@ -4,10 +4,15 @@ from typing import Any +from pyschlage.code import AccessCode +from pyschlage.exceptions import Error as SchlageError + from homeassistant.components.lock import LockEntity -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, ServiceResponse, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import DOMAIN from .coordinator import LockData, SchlageConfigEntry, SchlageDataUpdateCoordinator from .entity import SchlageEntity @@ -64,3 +69,108 @@ async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device.""" await self.hass.async_add_executor_job(self._lock.unlock) await self.coordinator.async_request_refresh() + + @staticmethod + def _normalize_code_name(name: str) -> str: + """Normalize a code name for comparison.""" + return name.lower().strip() + + def _validate_code_name( + self, codes: dict[str, AccessCode] | None, name: str + ) -> None: + """Validate that the code name doesn't already exist.""" + normalized = self._normalize_code_name(name) + if codes and any( + self._normalize_code_name(code.name) == normalized + for code in codes.values() + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="schlage_name_exists", + translation_placeholders={"name": name}, + ) + + def _validate_code_value( + self, codes: dict[str, AccessCode] | None, code: str + ) -> None: + """Validate that the code value doesn't already exist.""" + if codes and any( + existing_code.code == code for existing_code in codes.values() + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="schlage_code_exists", + ) + + async def _async_fetch_access_codes(self) -> dict[str, AccessCode] | None: + """Fetch access codes from the lock on demand.""" + try: + await self.hass.async_add_executor_job(self._lock.refresh_access_codes) + except SchlageError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="schlage_refresh_failed", + ) from ex + return self._lock.access_codes + + async def add_code(self, name: str, code: str) -> 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) + try: + await self.hass.async_add_executor_job( + self._lock.add_access_code, access_code + ) + except SchlageError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="schlage_add_code_failed", + ) from ex + await self.coordinator.async_request_refresh() + + async def delete_code(self, name: str) -> None: + """Delete a lock code.""" + codes = await self._async_fetch_access_codes() + if not codes: + return + + normalized = self._normalize_code_name(name) + code_id_to_delete = next( + ( + code_id + for code_id, code_data in codes.items() + if self._normalize_code_name(code_data.name) == normalized + ), + None, + ) + + if not code_id_to_delete: + # Code not found in defined codes, operation successful + return + + try: + await self.hass.async_add_executor_job(codes[code_id_to_delete].delete) + except SchlageError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="schlage_delete_code_failed", + ) from ex + await self.coordinator.async_request_refresh() + + async def get_codes(self) -> ServiceResponse: + """Get lock codes.""" + await self._async_fetch_access_codes() + + if self._lock.access_codes: + return { + code: { + "name": self._lock.access_codes[code].name, + "code": self._lock.access_codes[code].code, + } + for code in self._lock.access_codes + } + return {} diff --git a/homeassistant/components/schlage/services.yaml b/homeassistant/components/schlage/services.yaml new file mode 100644 index 00000000000000..97412251ea43c4 --- /dev/null +++ b/homeassistant/components/schlage/services.yaml @@ -0,0 +1,38 @@ +get_codes: + target: + entity: + domain: lock + integration: schlage + +add_code: + target: + entity: + domain: lock + integration: schlage + fields: + name: + required: true + example: "Example Person" + selector: + text: + multiline: false + code: + required: true + example: "1111" + selector: + text: + multiline: false + type: password + +delete_code: + target: + entity: + domain: lock + integration: schlage + fields: + name: + required: true + example: "Example Person" + selector: + text: + multiline: false diff --git a/homeassistant/components/schlage/strings.json b/homeassistant/components/schlage/strings.json index 838dc049808629..48f0232eb751af 100644 --- a/homeassistant/components/schlage/strings.json +++ b/homeassistant/components/schlage/strings.json @@ -56,8 +56,50 @@ } }, "exceptions": { + "schlage_add_code_failed": { + "message": "Failed to add PIN code to the lock." + }, + "schlage_code_exists": { + "message": "A PIN code with this value already exists on the lock." + }, + "schlage_delete_code_failed": { + "message": "Failed to delete PIN code from the lock." + }, + "schlage_name_exists": { + "message": "A PIN code with the name \"{name}\" already exists on the lock." + }, "schlage_refresh_failed": { - "message": "Failed to refresh Schlage data" + "message": "Failed to refresh Schlage data." + } + }, + "services": { + "add_code": { + "description": "Adds a PIN code to a lock.", + "fields": { + "code": { + "description": "The PIN code to add. Must be unique to the lock and be between 4 and 8 digits long.", + "name": "PIN code" + }, + "name": { + "description": "Name for PIN code. Must be case insensitively unique to the lock.", + "name": "PIN name" + } + }, + "name": "Add PIN code" + }, + "delete_code": { + "description": "Deletes a PIN code from a lock.", + "fields": { + "name": { + "description": "Name of PIN code to delete.", + "name": "[%key:component::schlage::services::add_code::fields::name::name%]" + } + }, + "name": "Delete PIN code" + }, + "get_codes": { + "description": "Retrieves all PIN codes from a lock.", + "name": "Get PIN codes" } } } diff --git a/homeassistant/components/schluter/climate.py b/homeassistant/components/schluter/climate.py index 581140d9406d59..94eb00fe11b98f 100644 --- a/homeassistant/components/schluter/climate.py +++ b/homeassistant/components/schluter/climate.py @@ -89,19 +89,15 @@ def __init__(self, coordinator, serial_number, api, session_id): self._serial_number = serial_number self._api = api self._session_id = session_id + self._attr_unique_id = serial_number @property - def unique_id(self): - """Return unique ID for this device.""" - return self._serial_number - - @property - def name(self): + def name(self) -> str: """Return the name of the thermostat.""" return self.coordinator.data[self._serial_number].name @property - def current_temperature(self): + def current_temperature(self) -> float: """Return the current temperature.""" return self.coordinator.data[self._serial_number].temperature @@ -113,7 +109,7 @@ def hvac_action(self) -> HVACAction: return HVACAction.IDLE @property - def target_temperature(self): + def target_temperature(self) -> float: """Return the temperature we try to reach.""" return self.coordinator.data[self._serial_number].set_point_temp diff --git a/homeassistant/components/scrape/coordinator.py b/homeassistant/components/scrape/coordinator.py index ea3d5054bdb94f..d491e5925e13e9 100644 --- a/homeassistant/components/scrape/coordinator.py +++ b/homeassistant/components/scrape/coordinator.py @@ -16,6 +16,13 @@ _LOGGER = logging.getLogger(__name__) +XML_MIME_TYPES = ( + "application/rss+xml", + "application/xhtml+xml", + "application/xml", + "text/xml", +) + class ScrapeCoordinator(DataUpdateCoordinator[BeautifulSoup]): """Scrape Coordinator.""" @@ -52,6 +59,33 @@ async def _async_update_data(self) -> BeautifulSoup: await self._rest.async_update() if (data := self._rest.data) is None: raise UpdateFailed("REST data is not available") - soup = await self.hass.async_add_executor_job(BeautifulSoup, data, "lxml") + + # Detect if content is XML and use appropriate parser + # Check Content-Type header first (most reliable), then fall back to content detection + parser = "lxml" + headers = self._rest.headers + content_type = headers.get("Content-Type", "") if headers else "" + if content_type.startswith(XML_MIME_TYPES): + parser = "lxml-xml" + elif isinstance(data, str): + data_stripped = data.lstrip() + if data_stripped.startswith("") + if xml_end != -1: + after_xml = data_stripped[xml_end + 2 :].lstrip() + after_xml_lower = after_xml.lower() + is_html = after_xml_lower.startswith((" bool: async def reload_service(service: ServiceCall) -> None: """Call a service to reload scripts.""" await async_get_blueprints(hass).async_reset_cache() - if (conf := await component.async_prepare_reload(skip_reset=True)) is None: - return + conf = await component.async_prepare_reload(skip_reset=True) await _async_process_config(hass, conf, component) async def turn_on_service(service: ServiceCall) -> None: diff --git a/homeassistant/components/scsgate/light.py b/homeassistant/components/scsgate/light.py index 0addbda9e09cb0..6729364ad19c79 100644 --- a/homeassistant/components/scsgate/light.py +++ b/homeassistant/components/scsgate/light.py @@ -68,7 +68,7 @@ def __init__(self, scs_id, name, logger, scsgate): """Initialize the light.""" self._attr_name = name self._scs_id = scs_id - self._toggled = False + self._attr_is_on = False self._logger = logger self._scsgate = scsgate @@ -77,17 +77,12 @@ def scs_id(self): """Return the SCS ID.""" return self._scs_id - @property - def is_on(self): - """Return true if light is on.""" - return self._toggled - def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" self._scsgate.append_task(ToggleStatusTask(target=self._scs_id, toggled=True)) - self._toggled = True + self._attr_is_on = True self.schedule_update_ha_state() def turn_off(self, **kwargs: Any) -> None: @@ -95,12 +90,12 @@ def turn_off(self, **kwargs: Any) -> None: self._scsgate.append_task(ToggleStatusTask(target=self._scs_id, toggled=False)) - self._toggled = False + self._attr_is_on = False self.schedule_update_ha_state() def process_event(self, message): """Handle a SCSGate message related with this light.""" - if self._toggled == message.toggled: + if self._attr_is_on == message.toggled: self._logger.info( "Light %s, ignoring message %s because state already active", self._scs_id, @@ -109,11 +104,11 @@ def process_event(self, message): # Nothing changed, ignoring return - self._toggled = message.toggled + self._attr_is_on = message.toggled self.schedule_update_ha_state() command = "off" - if self._toggled: + if self._attr_is_on: command = "on" self.hass.bus.fire( diff --git a/homeassistant/components/scsgate/switch.py b/homeassistant/components/scsgate/switch.py index 4607d65ac7ac6e..296c7097e062d7 100644 --- a/homeassistant/components/scsgate/switch.py +++ b/homeassistant/components/scsgate/switch.py @@ -100,9 +100,9 @@ class SCSGateSwitch(SwitchEntity): def __init__(self, scs_id, name, logger, scsgate): """Initialize the switch.""" - self._name = name + self._attr_name = name self._scs_id = scs_id - self._toggled = False + self._attr_is_on = False self._logger = logger self._scsgate = scsgate @@ -111,22 +111,12 @@ def scs_id(self): """Return the SCS ID.""" return self._scs_id - @property - def name(self): - """Return the name of the device if any.""" - return self._name - - @property - def is_on(self): - """Return true if switch is on.""" - return self._toggled - def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" self._scsgate.append_task(ToggleStatusTask(target=self._scs_id, toggled=True)) - self._toggled = True + self._attr_is_on = True self.schedule_update_ha_state() def turn_off(self, **kwargs: Any) -> None: @@ -134,12 +124,12 @@ def turn_off(self, **kwargs: Any) -> None: self._scsgate.append_task(ToggleStatusTask(target=self._scs_id, toggled=False)) - self._toggled = False + self._attr_is_on = False self.schedule_update_ha_state() def process_event(self, message): """Handle a SCSGate message related with this switch.""" - if self._toggled == message.toggled: + if self._attr_is_on == message.toggled: self._logger.info( "Switch %s, ignoring message %s because state already active", self._scs_id, @@ -148,11 +138,11 @@ def process_event(self, message): # Nothing changed, ignoring return - self._toggled = message.toggled + self._attr_is_on = message.toggled self.schedule_update_ha_state() command = "off" - if self._toggled: + if self._attr_is_on: command = "on" self.hass.bus.fire( diff --git a/homeassistant/components/season/sensor.py b/homeassistant/components/season/sensor.py index bdc24883c90575..e87a607b167321 100644 --- a/homeassistant/components/season/sensor.py +++ b/homeassistant/components/season/sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import date, datetime +from datetime import datetime import ephem @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util.dt import utcnow +from homeassistant.util import dt as dt_util from .const import DOMAIN, TYPE_ASTRONOMICAL @@ -50,7 +50,7 @@ async def async_setup_entry( def get_season( - current_date: date, hemisphere: str, season_tracking_type: str + current_datetime: datetime, hemisphere: str, season_tracking_type: str ) -> str | None: """Calculate the current season.""" @@ -58,22 +58,36 @@ def get_season( return None if season_tracking_type == TYPE_ASTRONOMICAL: - spring_start = ephem.next_equinox(str(current_date.year)).datetime() - summer_start = ephem.next_solstice(str(current_date.year)).datetime() - autumn_start = ephem.next_equinox(spring_start).datetime() - winter_start = ephem.next_solstice(summer_start).datetime() + spring_start = ( + ephem.next_equinox(str(current_datetime.year)) + .datetime() + .replace(tzinfo=dt_util.UTC) + ) + summer_start = ( + ephem.next_solstice(str(current_datetime.year)) + .datetime() + .replace(tzinfo=dt_util.UTC) + ) + autumn_start = ( + ephem.next_equinox(spring_start).datetime().replace(tzinfo=dt_util.UTC) + ) + winter_start = ( + ephem.next_solstice(summer_start).datetime().replace(tzinfo=dt_util.UTC) + ) else: - spring_start = datetime(2017, 3, 1).replace(year=current_date.year) + spring_start = current_datetime.replace( + month=3, day=1, hour=0, minute=0, second=0, microsecond=0 + ) summer_start = spring_start.replace(month=6) autumn_start = spring_start.replace(month=9) winter_start = spring_start.replace(month=12) season = STATE_WINTER - if spring_start <= current_date < summer_start: + if spring_start <= current_datetime < summer_start: season = STATE_SPRING - elif summer_start <= current_date < autumn_start: + elif summer_start <= current_datetime < autumn_start: season = STATE_SUMMER - elif autumn_start <= current_date < winter_start: + elif autumn_start <= current_datetime < winter_start: season = STATE_AUTUMN # If user is located in the southern hemisphere swap the season @@ -97,13 +111,11 @@ def __init__(self, entry: ConfigEntry, hemisphere: str) -> None: self.hemisphere = hemisphere self.type = entry.data[CONF_TYPE] self._attr_device_info = DeviceInfo( - name="Season", + translation_key="season", identifiers={(DOMAIN, entry.entry_id)}, entry_type=DeviceEntryType.SERVICE, ) def update(self) -> None: """Update season.""" - self._attr_native_value = get_season( - utcnow().replace(tzinfo=None), self.hemisphere, self.type - ) + self._attr_native_value = get_season(dt_util.now(), self.hemisphere, self.type) diff --git a/homeassistant/components/season/strings.json b/homeassistant/components/season/strings.json index f7ac146e835d60..2860f40b959b14 100644 --- a/homeassistant/components/season/strings.json +++ b/homeassistant/components/season/strings.json @@ -11,6 +11,11 @@ } } }, + "device": { + "season": { + "name": "[%key:component::season::title%]" + } + }, "entity": { "sensor": { "season": { diff --git a/homeassistant/components/select/icons.json b/homeassistant/components/select/icons.json index fbd1d4568f1072..84f61242bd2655 100644 --- a/homeassistant/components/select/icons.json +++ b/homeassistant/components/select/icons.json @@ -20,5 +20,10 @@ "select_previous": { "service": "mdi:format-list-bulleted" } + }, + "triggers": { + "selection_changed": { + "trigger": "mdi:format-list-bulleted" + } } } diff --git a/homeassistant/components/select/strings.json b/homeassistant/components/select/strings.json index 81c7a33b793b16..402bd31de9e12e 100644 --- a/homeassistant/components/select/strings.json +++ b/homeassistant/components/select/strings.json @@ -76,5 +76,11 @@ "name": "Previous" } }, - "title": "Select" + "title": "Select", + "triggers": { + "selection_changed": { + "description": "Triggers after the selected option of one or more dropdowns changes.", + "name": "Selection changed" + } + } } diff --git a/homeassistant/components/select/trigger.py b/homeassistant/components/select/trigger.py new file mode 100644 index 00000000000000..d33f0656c104e2 --- /dev/null +++ b/homeassistant/components/select/trigger.py @@ -0,0 +1,40 @@ +"""Provides triggers for selects.""" + +from homeassistant.components.input_select import DOMAIN as INPUT_SELECT_DOMAIN +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.trigger import ( + ENTITY_STATE_TRIGGER_SCHEMA, + EntityTriggerBase, + Trigger, +) + +from .const import DOMAIN + + +class SelectionChangedTrigger(EntityTriggerBase): + """Trigger for select entity when its selection changes.""" + + _domain_specs = {DOMAIN: DomainSpec(), INPUT_SELECT_DOMAIN: DomainSpec()} + _schema = ENTITY_STATE_TRIGGER_SCHEMA + + def is_valid_transition(self, from_state: State, to_state: State) -> bool: + """Check if the origin state is valid and the state has changed.""" + if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + return False + return from_state.state != to_state.state + + def is_valid_state(self, state: State) -> bool: + """Check if the new state is not invalid.""" + return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + + +TRIGGERS: dict[str, type[Trigger]] = { + "selection_changed": SelectionChangedTrigger, +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for selects.""" + return TRIGGERS diff --git a/homeassistant/components/select/triggers.yaml b/homeassistant/components/select/triggers.yaml new file mode 100644 index 00000000000000..1515ca9f43a5ca --- /dev/null +++ b/homeassistant/components/select/triggers.yaml @@ -0,0 +1,5 @@ +selection_changed: + target: + entity: + - domain: select + - domain: input_select diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index a1ee3e0417e07a..0a7fac2157657d 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -286,7 +286,7 @@ class SensorDeviceClass(StrEnum): NITROGEN_DIOXIDE = "nitrogen_dioxide" """Amount of NO2. - Unit of measurement: `ppb` (parts per billion), `μg/m³` + Unit of measurement: `ppb` (parts per billion), `ppm` (parts per million), `μg/m³` """ NITROGEN_MONOXIDE = "nitrogen_monoxide" @@ -639,6 +639,7 @@ class SensorStateClass(StrEnum): SensorDeviceClass.MOISTURE: {PERCENTAGE}, SensorDeviceClass.NITROGEN_DIOXIDE: { CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, }, SensorDeviceClass.NITROGEN_MONOXIDE: { diff --git a/homeassistant/components/sensorpro/manifest.json b/homeassistant/components/sensorpro/manifest.json index 88faa5a661fc38..36c599368e66ca 100644 --- a/homeassistant/components/sensorpro/manifest.json +++ b/homeassistant/components/sensorpro/manifest.json @@ -17,6 +17,7 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/sensorpro", + "integration_type": "device", "iot_class": "local_push", "requirements": ["sensorpro-ble==0.7.1"] } diff --git a/homeassistant/components/sensorpush/manifest.json b/homeassistant/components/sensorpush/manifest.json index 56db6f8f2808b6..8b5a093195e0c9 100644 --- a/homeassistant/components/sensorpush/manifest.json +++ b/homeassistant/components/sensorpush/manifest.json @@ -16,6 +16,7 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/sensorpush", + "integration_type": "device", "iot_class": "local_push", "requirements": ["sensorpush-ble==1.9.0"] } diff --git a/homeassistant/components/sensorpush_cloud/manifest.json b/homeassistant/components/sensorpush_cloud/manifest.json index 3de5c4b5c86f5d..e0b4b7d8ee8499 100644 --- a/homeassistant/components/sensorpush_cloud/manifest.json +++ b/homeassistant/components/sensorpush_cloud/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@sstallion"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sensorpush_cloud", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["sensorpush_api", "sensorpush_ha"], "quality_scale": "bronze", diff --git a/homeassistant/components/senz/__init__.py b/homeassistant/components/senz/__init__.py index a23ff7bb994497..ac3c1949c34c02 100644 --- a/homeassistant/components/senz/__init__.py +++ b/homeassistant/components/senz/__init__.py @@ -2,16 +2,14 @@ from __future__ import annotations -from datetime import timedelta from http import HTTPStatus import logging from aiohttp import ClientResponseError from httpx import HTTPStatusError, RequestError import jwt -from pysenz import SENZAPI, Thermostat +from pysenz import SENZAPI -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -21,12 +19,10 @@ OAuth2Session, async_get_config_entry_implementation, ) -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .api import SENZConfigEntryAuth from .const import DOMAIN - -UPDATE_INTERVAL = timedelta(seconds=30) +from .coordinator import SENZConfigEntry, SENZDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -34,9 +30,6 @@ PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] -type SENZDataUpdateCoordinator = DataUpdateCoordinator[dict[str, Thermostat]] -type SENZConfigEntry = ConfigEntry[SENZDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: SENZConfigEntry) -> bool: """Set up SENZ from a config entry.""" @@ -51,14 +44,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: SENZConfigEntry) -> bool auth = SENZConfigEntryAuth(httpx_client.get_async_client(hass), session) senz_api = SENZAPI(auth) - async def update_thermostats() -> dict[str, Thermostat]: - """Fetch SENZ thermostats data.""" - try: - thermostats = await senz_api.get_thermostats() - except RequestError as err: - raise UpdateFailed from err - return {thermostat.serial_number: thermostat for thermostat in thermostats} - try: account = await senz_api.get_account() except HTTPStatusError as err: @@ -92,13 +77,11 @@ async def update_thermostats() -> dict[str, Thermostat]: translation_key="config_entry_auth_failed", ) from err - coordinator: SENZDataUpdateCoordinator = DataUpdateCoordinator( + coordinator = SENZDataUpdateCoordinator( hass, - _LOGGER, - config_entry=entry, + entry, name=account.username, - update_interval=UPDATE_INTERVAL, - update_method=update_thermostats, + senz_api=senz_api, ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/senz/climate.py b/homeassistant/components/senz/climate.py index bde683d60d989d..9f5bc15e5bfdf9 100644 --- a/homeassistant/components/senz/climate.py +++ b/homeassistant/components/senz/climate.py @@ -20,8 +20,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import SENZConfigEntry, SENZDataUpdateCoordinator from .const import DOMAIN +from .coordinator import SENZConfigEntry, SENZDataUpdateCoordinator async def async_setup_entry( diff --git a/homeassistant/components/senz/coordinator.py b/homeassistant/components/senz/coordinator.py new file mode 100644 index 00000000000000..44f218d7b409ad --- /dev/null +++ b/homeassistant/components/senz/coordinator.py @@ -0,0 +1,51 @@ +"""Data update coordinator for SENZ.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from httpx import RequestError +from pysenz import SENZAPI, Thermostat + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +UPDATE_INTERVAL = timedelta(seconds=30) + +_LOGGER = logging.getLogger(__name__) + +type SENZConfigEntry = ConfigEntry[SENZDataUpdateCoordinator] + + +class SENZDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Thermostat]]): + """Class to manage fetching SENZ data.""" + + config_entry: SENZConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: SENZConfigEntry, + *, + name: str, + senz_api: SENZAPI, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=name, + update_interval=UPDATE_INTERVAL, + ) + self._senz_api = senz_api + + async def _async_update_data(self) -> dict[str, Thermostat]: + """Fetch data from SENZ.""" + try: + thermostats = await self._senz_api.get_thermostats() + except RequestError as err: + raise UpdateFailed from err + return {thermostat.serial_number: thermostat for thermostat in thermostats} diff --git a/homeassistant/components/senz/diagnostics.py b/homeassistant/components/senz/diagnostics.py index bed15a7091a6ce..909ee1619986fa 100644 --- a/homeassistant/components/senz/diagnostics.py +++ b/homeassistant/components/senz/diagnostics.py @@ -5,7 +5,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant -from . import SENZConfigEntry +from .coordinator import SENZConfigEntry TO_REDACT = [ "access_token", diff --git a/homeassistant/components/senz/manifest.json b/homeassistant/components/senz/manifest.json index 96f4f7e02b1e41..aca6bce3f946e3 100644 --- a/homeassistant/components/senz/manifest.json +++ b/homeassistant/components/senz/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/senz", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pysenz"], "requirements": ["pysenz==1.0.2"] diff --git a/homeassistant/components/senz/sensor.py b/homeassistant/components/senz/sensor.py index 74acf101ae0b8f..8f7eb2cc0ebe95 100644 --- a/homeassistant/components/senz/sensor.py +++ b/homeassistant/components/senz/sensor.py @@ -19,8 +19,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import SENZConfigEntry, SENZDataUpdateCoordinator from .const import DOMAIN +from .coordinator import SENZConfigEntry, SENZDataUpdateCoordinator @dataclass(kw_only=True, frozen=True) diff --git a/homeassistant/components/serial/sensor.py b/homeassistant/components/serial/sensor.py index 4d43408397f272..f4bfea72cb8061 100644 --- a/homeassistant/components/serial/sensor.py +++ b/homeassistant/components/serial/sensor.py @@ -131,8 +131,7 @@ def __init__( value_template, ): """Initialize the Serial sensor.""" - self._name = name - self._state = None + self._attr_name = name self._port = port self._baudrate = baudrate self._bytesize = bytesize @@ -143,7 +142,6 @@ def __init__( self._dsrdtr = dsrdtr self._serial_loop_task = None self._template = value_template - self._attributes = None async def async_added_to_hass(self) -> None: """Handle when an entity is about to be added to Home Assistant.""" @@ -215,7 +213,7 @@ async def serial_read( pass else: if isinstance(data, dict): - self._attributes = data + self._attr_extra_state_attributes = data if self._template is not None: line = self._template.async_render_with_possible_json_value( @@ -223,13 +221,13 @@ async def serial_read( ) _LOGGER.debug("Received: %s", line) - self._state = line + self._attr_native_value = line self.async_write_ha_state() async def _handle_error(self): """Handle error for serial connection.""" - self._state = None - self._attributes = None + self._attr_native_value = None + self._attr_extra_state_attributes = None self.async_write_ha_state() await asyncio.sleep(5) @@ -238,18 +236,3 @@ def stop_serial_read(self, event): """Close resources.""" if self._serial_loop_task: self._serial_loop_task.cancel() - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def extra_state_attributes(self): - """Return the attributes of the entity (if any JSON present).""" - return self._attributes - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index 1aa2b4fea69e8d..745b96bb2eb4e0 100644 --- a/homeassistant/components/seven_segments/manifest.json +++ b/homeassistant/components/seven_segments/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/seven_segments", "iot_class": "local_polling", "quality_scale": "legacy", - "requirements": ["Pillow==12.0.0"] + "requirements": ["Pillow==12.1.1"] } diff --git a/homeassistant/components/seventeentrack/manifest.json b/homeassistant/components/seventeentrack/manifest.json index 19daedb1b5edde..1064296fa61dca 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.1"] + "requirements": ["pyseventeentrack==1.1.2"] } diff --git a/homeassistant/components/sfr_box/strings.json b/homeassistant/components/sfr_box/strings.json index ff665c922d9049..52ba0b295cde62 100644 --- a/homeassistant/components/sfr_box/strings.json +++ b/homeassistant/components/sfr_box/strings.json @@ -17,7 +17,7 @@ "username": "[%key:common::config_flow::data::username%]" }, "data_description": { - "password": "The password for accessing your SFR box's web interface, the default is the WiFi security key found on the device label", + "password": "The password for accessing your SFR box's web interface, the default is the Wi-Fi security key found on the device label", "username": "The username for accessing your SFR box's web interface, the default is 'admin'" } }, diff --git a/homeassistant/components/sftp_storage/backup.py b/homeassistant/components/sftp_storage/backup.py index 4859f2d2f2afb5..2367d022a446d4 100644 --- a/homeassistant/components/sftp_storage/backup.py +++ b/homeassistant/components/sftp_storage/backup.py @@ -12,6 +12,7 @@ BackupAgent, BackupAgentError, BackupNotFound, + OnProgressCallback, ) from homeassistant.core import HomeAssistant, callback @@ -85,6 +86,7 @@ async def async_upload_backup( *, open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], backup: AgentBackup, + on_progress: OnProgressCallback, **kwargs: Any, ) -> None: """Upload a backup.""" diff --git a/homeassistant/components/sftp_storage/config_flow.py b/homeassistant/components/sftp_storage/config_flow.py index 3168810edab49c..cecd7d54b3579e 100644 --- a/homeassistant/components/sftp_storage/config_flow.py +++ b/homeassistant/components/sftp_storage/config_flow.py @@ -124,6 +124,17 @@ async def async_step_user( } ) + if not user_input[CONF_BACKUP_LOCATION].startswith("/"): + errors[CONF_BACKUP_LOCATION] = "backup_location_relative" + return self.async_show_form( + step_id=step_id, + data_schema=self.add_suggested_values_to_schema( + DATA_SCHEMA, user_input + ), + description_placeholders=placeholders, + errors=errors, + ) + try: # Validate auth input and save uploaded key file if provided user_input = await self._validate_auth_and_save_keyfile(user_input) diff --git a/homeassistant/components/sftp_storage/strings.json b/homeassistant/components/sftp_storage/strings.json index 9856286a0f10c6..dce60e9e3e5e8e 100644 --- a/homeassistant/components/sftp_storage/strings.json +++ b/homeassistant/components/sftp_storage/strings.json @@ -4,6 +4,7 @@ "already_configured": "Integration already configured. Host with same address, port and backup location already exists." }, "error": { + "backup_location_relative": "The remote path must be an absolute path (starting with `/`).", "invalid_key": "Invalid key uploaded. Please make sure key corresponds to valid SSH key algorithm.", "key_or_password_needed": "Please configure password or private key file location for SFTP Storage.", "os_error": "{error_message}. Please check if host and/or port are correct.", diff --git a/homeassistant/components/sharkiq/manifest.json b/homeassistant/components/sharkiq/manifest.json index 4b669ae7b7fff6..02bb3419000bf9 100644 --- a/homeassistant/components/sharkiq/manifest.json +++ b/homeassistant/components/sharkiq/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@JeffResc", "@funkybunch", "@TheOneOgre"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sharkiq", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["sharkiq"], "requirements": ["sharkiq==1.5.0"] diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index f5cff59a2e95c5..2120f5e50e6328 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -66,6 +66,7 @@ from .services import async_setup_services from .utils import ( async_create_issue_unsupported_firmware, + async_migrate_rpc_sensor_description_unique_ids, async_migrate_rpc_virtual_components_unique_ids, get_coap_context, get_device_entry_gen, @@ -296,6 +297,12 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) runtime_data = entry.runtime_data runtime_data.platforms = RPC_SLEEPING_PLATFORMS + await er.async_migrate_entries( + hass, + entry.entry_id, + async_migrate_rpc_sensor_description_unique_ids, + ) + if sleep_period == 0: # Not a sleeping device, finish setup LOGGER.debug("Setting up online RPC device %s", entry.title) diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index ca93da3ee1fd85..632e5277de5f2e 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -8,7 +8,7 @@ from aioshelly.const import MODEL_FLOOD_G4, RPC_GENERATIONS from homeassistant.components.binary_sensor import ( - DOMAIN as BINARY_SENSOR_PLATFORM, + DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, @@ -292,7 +292,7 @@ def __init__( key="boolean", sub_key="value", removal_condition=lambda config, _, key: ( - not is_view_for_platform(config, key, BINARY_SENSOR_PLATFORM) + not is_view_for_platform(config, key, BINARY_SENSOR_DOMAIN) ), role=ROLE_GENERIC, ), @@ -424,7 +424,7 @@ def _async_setup_rpc_entry( hass, config_entry.entry_id, coordinator.mac, - BINARY_SENSOR_PLATFORM, + BINARY_SENSOR_DOMAIN, coordinator.device.status, ) diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index 7ee8794e509216..9fb3cb895160b7 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -11,7 +11,7 @@ from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError from homeassistant.components.button import ( - DOMAIN as BUTTON_PLATFORM, + DOMAIN as BUTTON_DOMAIN, ButtonDeviceClass, ButtonEntity, ButtonEntityDescription, @@ -217,7 +217,7 @@ async def async_setup_entry( # added in https://github.com/home-assistant/core/pull/154673 entry_sleep_period = config_entry.data[CONF_SLEEP_PERIOD] if device_gen in RPC_GENERATIONS and entry_sleep_period: - async_remove_shelly_entity(hass, BUTTON_PLATFORM, f"{coordinator.mac}-reboot") + async_remove_shelly_entity(hass, BUTTON_DOMAIN, f"{coordinator.mac}-reboot") entities: list[ShellyButton] = [] @@ -249,13 +249,13 @@ async def async_setup_entry( # the user can remove virtual components from the device configuration, so # we need to remove orphaned entities virtual_button_component_ids = get_virtual_component_ids( - coordinator.device.config, BUTTON_PLATFORM + coordinator.device.config, BUTTON_DOMAIN ) async_remove_orphaned_entities( hass, config_entry.entry_id, coordinator.mac, - BUTTON_PLATFORM, + BUTTON_DOMAIN, virtual_button_component_ids, ) diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 857c79a0335aa1..305dd5ebd70d4d 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -11,7 +11,7 @@ from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError from homeassistant.components.number import ( - DOMAIN as NUMBER_PLATFORM, + DOMAIN as NUMBER_DOMAIN, NumberDeviceClass, NumberEntity, NumberEntityDescription, @@ -210,7 +210,7 @@ async def async_set_native_value(self, value: float) -> None: key="number", sub_key="value", removal_condition=lambda config, _, key: ( - not is_view_for_platform(config, key, NUMBER_PLATFORM) + not is_view_for_platform(config, key, NUMBER_DOMAIN) ), max_fn=lambda config: config["max"], min_fn=lambda config: config["min"], @@ -380,13 +380,13 @@ def _async_setup_rpc_entry( # the user can remove virtual components from the device configuration, so # we need to remove orphaned entities virtual_number_ids = get_virtual_component_ids( - coordinator.device.config, NUMBER_PLATFORM + coordinator.device.config, NUMBER_DOMAIN ) async_remove_orphaned_entities( hass, config_entry.entry_id, coordinator.mac, - NUMBER_PLATFORM, + NUMBER_DOMAIN, virtual_number_ids, "number", ) diff --git a/homeassistant/components/shelly/select.py b/homeassistant/components/shelly/select.py index afc86f4a54e3ed..262efcd01ee758 100644 --- a/homeassistant/components/shelly/select.py +++ b/homeassistant/components/shelly/select.py @@ -8,7 +8,7 @@ from aioshelly.const import RPC_GENERATIONS from homeassistant.components.select import ( - DOMAIN as SELECT_PLATFORM, + DOMAIN as SELECT_DOMAIN, SelectEntity, SelectEntityDescription, ) @@ -117,7 +117,7 @@ def current_option(self) -> str | None: key="enum", sub_key="value", removal_condition=lambda config, _status, key: ( - not is_view_for_platform(config, key, SELECT_PLATFORM) + not is_view_for_platform(config, key, SELECT_DOMAIN) ), method="enum_set", role=ROLE_GENERIC, @@ -154,13 +154,13 @@ def _async_setup_rpc_entry( # the user can remove virtual components from the device configuration, so # we need to remove orphaned entities virtual_text_ids = get_virtual_component_ids( - coordinator.device.config, SELECT_PLATFORM + coordinator.device.config, SELECT_DOMAIN ) async_remove_orphaned_entities( hass, config_entry.entry_id, coordinator.mac, - SELECT_PLATFORM, + SELECT_DOMAIN, virtual_text_ids, "enum", ) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index b7cc317cb9d3ca..5eeb818c59a56d 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -9,7 +9,7 @@ from aioshelly.const import RPC_GENERATIONS from homeassistant.components.sensor import ( - DOMAIN as SENSOR_PLATFORM, + DOMAIN as SENSOR_DOMAIN, RestoreSensor, SensorDeviceClass, SensorEntity, @@ -1220,7 +1220,7 @@ def __init__( entity_category=EntityCategory.DIAGNOSTIC, use_polling_coordinator=True, ), - "temperature_0": RpcSensorDescription( + "temperature_tc": RpcSensorDescription( key="temperature", sub_key="tC", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -1249,7 +1249,7 @@ def __init__( entity_category=EntityCategory.DIAGNOSTIC, use_polling_coordinator=True, ), - "humidity_0": RpcSensorDescription( + "humidity_rh": RpcSensorDescription( key="humidity", sub_key="rh", native_unit_of_measurement=PERCENTAGE, @@ -1357,7 +1357,7 @@ def __init__( key="text", sub_key="value", removal_condition=lambda config, _, key: ( - not is_view_for_platform(config, key, SENSOR_PLATFORM) + not is_view_for_platform(config, key, SENSOR_DOMAIN) ), role=ROLE_GENERIC, ), @@ -1365,7 +1365,7 @@ def __init__( key="number", sub_key="value", removal_condition=lambda config, _, key: ( - not is_view_for_platform(config, key, SENSOR_PLATFORM) + not is_view_for_platform(config, key, SENSOR_DOMAIN) ), unit=get_virtual_component_unit, role=ROLE_GENERIC, @@ -1374,7 +1374,7 @@ def __init__( key="enum", sub_key="value", removal_condition=lambda config, _, key: ( - not is_view_for_platform(config, key, SENSOR_PLATFORM) + not is_view_for_platform(config, key, SENSOR_DOMAIN) ), device_class=SensorDeviceClass.ENUM, role=ROLE_GENERIC, @@ -1792,7 +1792,7 @@ def _async_setup_rpc_entry( hass, config_entry.entry_id, coordinator.mac, - SENSOR_PLATFORM, + SENSOR_DOMAIN, coordinator.device.status, ) diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 67fb40b8c5b615..b61ce0af7724f9 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -2,21 +2,21 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "already_on_wifi": "Device is already connected to WiFi and was discovered via the network.", - "another_device": "Re-configuration was unsuccessful, the IP address/hostname of another Shelly device was used.", - "ble_not_permitted": "Device is bound to a Shelly cloud account and cannot be provisioned via Bluetooth. Please use the Shelly app to provision WiFi credentials, then add the device when it appears on your network.", + "already_on_wifi": "Device is already connected to Wi-Fi and was discovered via the network.", + "another_device": "Reconfiguration was unsuccessful, the IP address/hostname of another Shelly device was used.", + "ble_not_permitted": "Device is bound to a Shelly cloud account and cannot be provisioned via Bluetooth. Please use the Shelly app to provision Wi-Fi credentials, then add the device when it appears on your network.", "cannot_connect": "Failed to connect to the device. Ensure the device is powered on and within range.", "custom_port_not_supported": "[%key:component::shelly::config::error::custom_port_not_supported%]", "firmware_not_fully_provisioned": "Device not fully provisioned. Please contact Shelly support", "invalid_discovery_info": "Invalid Bluetooth discovery information.", "ipv6_not_supported": "IPv6 is not supported.", "mac_address_mismatch": "[%key:component::shelly::config::error::mac_address_mismatch%]", - "no_wifi_networks": "No WiFi networks found during scan.", + "no_wifi_networks": "No Wi-Fi networks found during scan.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reauth_unsuccessful": "Re-authentication was unsuccessful, please remove the integration and set it up again.", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "wifi_provisioned": "WiFi credentials for {ssid} have been provisioned to {name}. The device is connecting to WiFi and will complete setup automatically." + "wifi_provisioned": "Wi-Fi credentials for {ssid} have been provisioned to {name}. The device is connecting to Wi-Fi and will complete setup automatically." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -28,20 +28,20 @@ }, "flow_title": "{name}", "progress": { - "provisioning": "Provisioning WiFi credentials and waiting for device to connect" + "provisioning": "Provisioning Wi-Fi credentials and waiting for device to connect" }, "step": { "bluetooth_confirm": { "data": { - "disable_ap": "Disable WiFi access point after provisioning", + "disable_ap": "Disable Wi-Fi access point after provisioning", "disable_ble_rpc": "Disable Bluetooth RPC after provisioning" }, "data_description": { - "disable_ap": "For improved security, disable the WiFi access point after successfully connecting to your network.", - "disable_ble_rpc": "For improved security, disable Bluetooth RPC access after WiFi is configured. Bluetooth will remain enabled for BLE sensors and buttons." + "disable_ap": "For improved security, disable the Wi-Fi access point after successfully connecting to your network.", + "disable_ble_rpc": "For improved security, disable Bluetooth RPC access after Wi-Fi is configured. Bluetooth will remain enabled for BLE sensors and buttons." }, - "description": "The Shelly device {name} has been discovered via Bluetooth but is not connected to WiFi.\n\nDo you want to provision WiFi credentials to this device?", - "title": "Provision WiFi via Bluetooth" + "description": "The Shelly device {name} has been discovered via Bluetooth but is not connected to Wi-Fi.\n\nDo you want to provision Wi-Fi credentials to this device?", + "title": "Provision Wi-Fi via Bluetooth" }, "confirm_discovery": { "description": "Do you want to set up the {model} at {host}?\n\nBattery-powered devices that are password-protected must be woken up before continuing with setting up.\nBattery-powered devices that are not password-protected will be added when the device wakes up, you can now manually wake the device up using a button on it or wait for the next data update from the device." @@ -103,16 +103,16 @@ "wifi_scan": { "data": { "password": "[%key:common::config_flow::data::password%]", - "ssid": "WiFi network" + "ssid": "Wi-Fi network" }, "data_description": { - "password": "Password for the WiFi network.", - "ssid": "Select a WiFi network from the list or enter a custom SSID for hidden networks." + "password": "Password for the Wi-Fi network.", + "ssid": "Select a Wi-Fi network from the list or enter a custom SSID for hidden networks." }, - "description": "Select a WiFi network and enter the password to provision the device." + "description": "Select a Wi-Fi network and enter the password to provision the device." }, "wifi_scan_failed": { - "description": "Failed to scan for WiFi networks via Bluetooth. The device may be out of range or Bluetooth connection failed. Would you like to try again?" + "description": "Failed to scan for Wi-Fi networks via Bluetooth. The device may be out of range or Bluetooth connection failed. Would you like to try again?" } } }, @@ -727,16 +727,16 @@ }, "step": { "init": { - "description": "Your Shelly device {device_name} with IP address {ip_address} has an open WiFi access point enabled without a password. This is a security risk as anyone nearby can connect to the device.\n\nNote: If you disable the access point, the device may need to restart.", + "description": "Your Shelly device {device_name} with IP address {ip_address} has an open Wi-Fi access point enabled without a password. This is a security risk as anyone nearby can connect to the device.\n\nNote: If you disable the access point, the device may need to restart.", "menu_options": { - "confirm": "Disable WiFi access point", + "confirm": "Disable Wi-Fi access point", "ignore": "Ignore" }, "title": "[%key:component::shelly::issues::open_wifi_ap::title%]" } } }, - "title": "Open WiFi access point on {device_name}" + "title": "Open Wi-Fi access point on {device_name}" }, "outbound_websocket_incorrectly_enabled": { "fix_flow": { diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 3cad237bc9a83a..5a4f8debd1b430 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -9,9 +9,9 @@ from aioshelly.block_device import Block from aioshelly.const import RPC_GENERATIONS -from homeassistant.components.climate import DOMAIN as CLIMATE_PLATFORM +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.switch import ( - DOMAIN as SWITCH_PLATFORM, + DOMAIN as SWITCH_DOMAIN, SwitchEntity, SwitchEntityDescription, ) @@ -101,7 +101,7 @@ class RpcSwitchDescription(RpcEntityDescription, SwitchEntityDescription): key="boolean", sub_key="value", removal_condition=lambda config, _, key: ( - not is_view_for_platform(config, key, SWITCH_PLATFORM) + not is_view_for_platform(config, key, SWITCH_DOMAIN) ), is_on=lambda status: bool(status["value"]), method_on="boolean_set", @@ -379,13 +379,13 @@ def _async_setup_rpc_entry( # the user can remove virtual components from the device configuration, so we need # to remove orphaned entities virtual_switch_ids = get_virtual_component_ids( - coordinator.device.config, SWITCH_PLATFORM + coordinator.device.config, SWITCH_DOMAIN ) async_remove_orphaned_entities( hass, config_entry.entry_id, coordinator.mac, - SWITCH_PLATFORM, + SWITCH_DOMAIN, virtual_switch_ids, "boolean", ) @@ -396,7 +396,7 @@ def _async_setup_rpc_entry( hass, config_entry.entry_id, coordinator.mac, - SWITCH_PLATFORM, + SWITCH_DOMAIN, coordinator.device.status, "script", ) @@ -407,7 +407,7 @@ def _async_setup_rpc_entry( hass, config_entry.entry_id, coordinator.mac, - CLIMATE_PLATFORM, + CLIMATE_DOMAIN, coordinator.device.status, "thermostat", ) diff --git a/homeassistant/components/shelly/text.py b/homeassistant/components/shelly/text.py index 2ba043e5c2801a..4d526f65a7e9ea 100644 --- a/homeassistant/components/shelly/text.py +++ b/homeassistant/components/shelly/text.py @@ -8,7 +8,7 @@ from aioshelly.const import RPC_GENERATIONS from homeassistant.components.text import ( - DOMAIN as TEXT_PLATFORM, + DOMAIN as TEXT_DOMAIN, TextEntity, TextEntityDescription, ) @@ -44,7 +44,7 @@ class RpcTextDescription(RpcEntityDescription, TextEntityDescription): key="text", sub_key="value", removal_condition=lambda config, _status, key: ( - not is_view_for_platform(config, key, TEXT_PLATFORM) + not is_view_for_platform(config, key, TEXT_DOMAIN) ), role=ROLE_GENERIC, ), @@ -79,14 +79,12 @@ def _async_setup_rpc_entry( # the user can remove virtual components from the device configuration, so # we need to remove orphaned entities - virtual_text_ids = get_virtual_component_ids( - coordinator.device.config, TEXT_PLATFORM - ) + virtual_text_ids = get_virtual_component_ids(coordinator.device.config, TEXT_DOMAIN) async_remove_orphaned_entities( hass, config_entry.entry_id, coordinator.mac, - TEXT_PLATFORM, + TEXT_DOMAIN, virtual_text_ids, "text", ) diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index b7da839e6fccd4..27afa335e5e478 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -969,6 +969,30 @@ def format_ble_addr(ble_addr: str) -> str: return ble_addr.replace(":", "").upper() +@callback +def async_migrate_rpc_sensor_description_unique_ids( + entity_entry: er.RegistryEntry, +) -> dict[str, Any] | None: + """Migrate RPC sensor unique_ids after sensor description key rename.""" + unique_id_map = { + "-temperature_0": "-temperature_tc", + "-humidity_0": "-humidity_rh", + } + + for old_suffix, new_suffix in unique_id_map.items(): + if entity_entry.unique_id.endswith(old_suffix): + new_unique_id = entity_entry.unique_id.removesuffix(old_suffix) + new_suffix + LOGGER.debug( + "Migrating unique_id for %s entity from [%s] to [%s]", + entity_entry.entity_id, + entity_entry.unique_id, + new_unique_id, + ) + return {"new_unique_id": new_unique_id} + + return None + + @callback def async_migrate_rpc_virtual_components_unique_ids( config: dict[str, Any], entity_entry: er.RegistryEntry diff --git a/homeassistant/components/sia/manifest.json b/homeassistant/components/sia/manifest.json index a6b612a8acff05..19d6f07dca2be0 100644 --- a/homeassistant/components/sia/manifest.json +++ b/homeassistant/components/sia/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@eavanvalkenburg"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sia", + "integration_type": "hub", "iot_class": "local_push", "loggers": ["pysiaalarm"], "requirements": ["pysiaalarm==3.2.2"] diff --git a/homeassistant/components/sigfox/sensor.py b/homeassistant/components/sigfox/sensor.py index aece5675cbca52..667d4a50602b76 100644 --- a/homeassistant/components/sigfox/sensor.py +++ b/homeassistant/components/sigfox/sensor.py @@ -6,6 +6,7 @@ from http import HTTPStatus import json import logging +from typing import Any from urllib.parse import urljoin import requests @@ -123,11 +124,9 @@ def __init__(self, device_id, auth, name): """Initialise the device object.""" self._device_id = device_id self._auth = auth - self._message_data = {} - self._name = f"{name}_{device_id}" - self._state = None + self._attr_name = f"{name}_{device_id}" - def get_last_message(self): + def get_last_message(self) -> dict[str, Any]: """Return the last message from a device.""" device_url = f"devices/{self._device_id}/messages?limit=1" url = urljoin(API_URL, device_url) @@ -148,20 +147,5 @@ def get_last_message(self): def update(self) -> None: """Fetch the latest device message.""" - self._message_data = self.get_last_message() - self._state = self._message_data["payload"] - - @property - def name(self): - """Return the HA name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the payload of the last message.""" - return self._state - - @property - def extra_state_attributes(self): - """Return other details about the last message.""" - return self._message_data + self._attr_extra_state_attributes = self.get_last_message() + self._attr_native_value = self._attr_extra_state_attributes["payload"] diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json index 596e9c1751a840..64ba7361aeb666 100644 --- a/homeassistant/components/sighthound/manifest.json +++ b/homeassistant/components/sighthound/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["simplehound"], "quality_scale": "legacy", - "requirements": ["Pillow==12.0.0", "simplehound==0.3"] + "requirements": ["Pillow==12.1.1", "simplehound==0.3"] } diff --git a/homeassistant/components/simplepush/manifest.json b/homeassistant/components/simplepush/manifest.json index 5b792072f4479d..54b55475465a96 100644 --- a/homeassistant/components/simplepush/manifest.json +++ b/homeassistant/components/simplepush/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@engrbm87"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/simplepush", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["simplepush"], "requirements": ["simplepush==2.2.3"] diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 8e964e0c7769f8..d9ab3e3b4f137f 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -4,13 +4,13 @@ import asyncio from collections.abc import Callable, Coroutine -from datetime import timedelta from typing import Any, cast from simplipy import API from simplipy.errors import ( EndpointUnavailableError, InvalidCredentialsError, + RequestError, SimplipyError, WebsocketError, ) @@ -46,10 +46,9 @@ CONF_CODE, CONF_TOKEN, CONF_USERNAME, - EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import CoreState, Event, HomeAssistant, ServiceCall, callback +from homeassistant.core import CoreState, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, @@ -65,7 +64,7 @@ async_register_admin_service, verify_domain_control, ) -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import UpdateFailed from .const import ( ATTR_ALARM_DURATION, @@ -86,6 +85,7 @@ DOMAIN, LOGGER, ) +from .coordinator import SimpliSafeDataUpdateCoordinator from .typing import SystemType ATTR_CATEGORY = "category" @@ -99,10 +99,9 @@ ATTR_PIN_VALUE = "pin" ATTR_TIMESTAMP = "timestamp" -DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) - WEBSOCKET_RECONNECT_RETRIES = 3 WEBSOCKET_RETRY_DELAY = 2 +WEBSOCKET_LOOP_TASK_NAME = "simplisafe websocket task" EVENT_SIMPLISAFE_EVENT = "SIMPLISAFE_EVENT" EVENT_SIMPLISAFE_NOTIFICATION = "SIMPLISAFE_NOTIFICATION" @@ -420,15 +419,14 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry, api: API) -> None: self._api = api self._hass = hass self._system_notifications: dict[int, set[SystemNotification]] = {} - self._websocket_reconnect_retries: int = 0 - self._websocket_reconnect_task: asyncio.Task | None = None + self._websocket_task: asyncio.Task | None = None self.entry = entry self.initial_event_to_use: dict[int, dict[str, Any]] = {} self.subscription_data: dict[int, Any] = api.subscription_data self.systems: dict[int, SystemType] = {} # This will get filled in by async_init: - self.coordinator: DataUpdateCoordinator[None] | None = None + self.coordinator: SimpliSafeDataUpdateCoordinator | None = None @callback def _async_process_new_notifications(self, system: SystemType) -> None: @@ -467,53 +465,69 @@ def _async_process_new_notifications(self, system: SystemType) -> None: self._system_notifications[system.system_id] = latest_notifications - async def _async_start_websocket_loop(self) -> None: - """Start a websocket reconnection loop.""" - assert self._api.websocket - - self._websocket_reconnect_retries += 1 - - try: - await self._api.websocket.async_connect() - await self._api.websocket.async_listen() - except asyncio.CancelledError: - LOGGER.debug("Request to cancel websocket loop received") - raise - except WebsocketError as err: - LOGGER.error("Failed to connect to websocket: %s", err) - except Exception as err: # noqa: BLE001 - LOGGER.error("Unknown exception while connecting to websocket: %s", err) - else: - self._websocket_reconnect_retries = 0 + @callback + def _async_start_websocket_if_needed(self) -> None: + """Start the websocket loop task if it isn't already running.""" + task = self._websocket_task - if self._websocket_reconnect_retries >= WEBSOCKET_RECONNECT_RETRIES: - LOGGER.error("Max websocket connection retries exceeded") + if task and not task.done(): return - delay = WEBSOCKET_RETRY_DELAY * (2 ** (self._websocket_reconnect_retries - 1)) - LOGGER.info( - "Retrying websocket connection in %s seconds (attempt %s/%s)", - delay, - self._websocket_reconnect_retries, - WEBSOCKET_RECONNECT_RETRIES, - ) - await asyncio.sleep(delay) - self._websocket_reconnect_task = self._hass.async_create_task( - self._async_start_websocket_loop() + LOGGER.debug("Starting websocket loop task") + + self._websocket_task = self.entry.async_create_background_task( + self._hass, self._async_websocket_loop(), WEBSOCKET_LOOP_TASK_NAME ) - async def _async_cancel_websocket_loop(self) -> None: - """Stop any existing websocket reconnection loop.""" - if self._websocket_reconnect_task: - self._websocket_reconnect_task.cancel() + async def _async_websocket_loop(self) -> None: + assert self._api.websocket + + retries = 0 + while True: try: - await self._websocket_reconnect_task + await self._api.websocket.async_connect() + await self._api.websocket.async_listen() except asyncio.CancelledError: - LOGGER.debug("Websocket reconnection task successfully canceled") - self._websocket_reconnect_task = None + await self._api.websocket.async_disconnect() + raise + except WebsocketError as err: + retries += 1 + delay = WEBSOCKET_RETRY_DELAY * (2 ** (retries - 1)) + LOGGER.debug( + "Websocket error (%s/%s): %s; retrying in %s seconds", + retries, + WEBSOCKET_RECONNECT_RETRIES, + err, + delay, + ) + + await asyncio.sleep(delay) + if retries >= WEBSOCKET_RECONNECT_RETRIES: + LOGGER.error( + "Websocket connection failed, task exiting (%s/%s): %s", + retries, + WEBSOCKET_RECONNECT_RETRIES, + err, + ) + return + except Exception as err: # noqa: BLE001 + # unexpected errors → log and stop + LOGGER.exception("Unexpected error in websocket loop: %s", err) + return - assert self._api.websocket - await self._api.websocket.async_disconnect() + async def _async_cancel_websocket_loop(self) -> None: + """Cancel the websocket loop task, if running.""" + task = self._websocket_task + if not task: + return + + self._websocket_task = None + task.cancel() + + try: + await task + except asyncio.CancelledError: + LOGGER.debug("Websocket loop task cancelled") @callback def _async_websocket_on_event(self, event: WebsocketEvent) -> None: @@ -553,20 +567,7 @@ async def async_init(self) -> None: assert self._api.websocket self._api.websocket.add_event_callback(self._async_websocket_on_event) - self._websocket_reconnect_task = asyncio.create_task( - self._async_start_websocket_loop() - ) - - async def async_websocket_disconnect_listener(_: Event) -> None: - """Define an event handler to disconnect from the websocket.""" - assert self._api.websocket - await self._async_cancel_websocket_loop() - - self.entry.async_on_unload( - self._hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, async_websocket_disconnect_listener - ) - ) + self._async_start_websocket_if_needed() self.systems = await self._api.async_get_systems() for system in self.systems.values(): @@ -585,13 +586,11 @@ async def async_websocket_disconnect_listener(_: Event) -> None: LOGGER.error("Error while fetching initial event: %s", err) self.initial_event_to_use[system.system_id] = {} - self.coordinator = DataUpdateCoordinator( + self.coordinator = SimpliSafeDataUpdateCoordinator( self._hass, - LOGGER, + self.entry, name=self.entry.title, - config_entry=self.entry, - update_interval=DEFAULT_SCAN_INTERVAL, - update_method=self.async_update, + simplisafe=self, ) @callback @@ -610,9 +609,7 @@ async def async_handle_refresh_token(token: str) -> None: # Open a new websocket connection with the fresh token: assert self._api.websocket await self._async_cancel_websocket_loop() - self._websocket_reconnect_task = self._hass.async_create_task( - self._async_start_websocket_loop() - ) + self._async_start_websocket_if_needed() self.entry.async_on_unload( self._api.add_refresh_token_callback(async_handle_refresh_token) @@ -625,22 +622,37 @@ async def async_update(self) -> None: """Get updated data from SimpliSafe.""" async def async_update_system(system: SystemType) -> None: - """Update a system.""" + """Update a single system and process notifications.""" await system.async_update(cached=system.version != 3) self._async_process_new_notifications(system) tasks = [async_update_system(system) for system in self.systems.values()] - results = await asyncio.gather(*tasks, return_exceptions=True) - for result in results: - if isinstance(result, InvalidCredentialsError): - raise ConfigEntryAuthFailed("Invalid credentials") from result - - if isinstance(result, EndpointUnavailableError): - # In case the user attempts an action not allowed in their current plan, - # we merely log that message at INFO level (so the user is aware, - # but not spammed with ERROR messages that they cannot change): - LOGGER.debug(result) - - if isinstance(result, SimplipyError): - raise UpdateFailed(f"SimpliSafe error while updating: {result}") + try: + # Gather all system updates; exceptions will propagate + await asyncio.gather(*tasks) + except InvalidCredentialsError as err: + # Stop websocket immediately on auth failure + if self._websocket_task: + LOGGER.debug("Cancelling websocket loop due to invalid credentials") + await self._async_cancel_websocket_loop() + # Signal HA that credentials are invalid; user intervention is required + raise ConfigEntryAuthFailed("Invalid credentials") from err + except RequestError as err: + # Cloud-level request errors: wrap aiohttp errors + if self._websocket_task: + LOGGER.debug("Cancelling websocket loop due to request error") + await self._async_cancel_websocket_loop() + raise UpdateFailed( + f"Request error while updating all systems: {err}" + ) from err + except EndpointUnavailableError as err: + # Currently not raised by the API; included for future-proofing. + # Informational per-system (e.g., user plan restrictions) + LOGGER.debug("Endpoint unavailable: %s", err) + except SimplipyError as err: + # Any other SimplipyError not caught per-system + raise UpdateFailed(f"SimpliSafe error while updating: {err}") from err + else: + # Successful update, try to restart websocket if necessary + self._async_start_websocket_if_needed() diff --git a/homeassistant/components/simplisafe/coordinator.py b/homeassistant/components/simplisafe/coordinator.py new file mode 100644 index 00000000000000..bde2a939882b74 --- /dev/null +++ b/homeassistant/components/simplisafe/coordinator.py @@ -0,0 +1,45 @@ +"""Data update coordinator for SimpliSafe.""" + +from __future__ import annotations + +from datetime import timedelta +from typing import TYPE_CHECKING + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import LOGGER + +if TYPE_CHECKING: + from . import SimpliSafe + +DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) + + +class SimpliSafeDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Class to manage fetching SimpliSafe data.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + *, + name: str, + simplisafe: SimpliSafe, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + name=name, + config_entry=config_entry, + update_interval=DEFAULT_SCAN_INTERVAL, + ) + self._simplisafe = simplisafe + + async def _async_update_data(self) -> None: + """Fetch data from SimpliSafe.""" + await self._simplisafe.async_update() diff --git a/homeassistant/components/simplisafe/entity.py b/homeassistant/components/simplisafe/entity.py index 27d7d8f2b4ddb6..eff3f8d3998cc8 100644 --- a/homeassistant/components/simplisafe/entity.py +++ b/homeassistant/components/simplisafe/entity.py @@ -20,10 +20,7 @@ from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import SimpliSafe from .const import ( @@ -36,6 +33,7 @@ DOMAIN, LOGGER, ) +from .coordinator import SimpliSafeDataUpdateCoordinator from .typing import SystemType DEFAULT_CONFIG_URL = "https://webapp.simplisafe.com/new/#/dashboard" @@ -49,7 +47,7 @@ ] -class SimpliSafeEntity(CoordinatorEntity[DataUpdateCoordinator[None]]): +class SimpliSafeEntity(CoordinatorEntity[SimpliSafeDataUpdateCoordinator]): """Define a base SimpliSafe entity.""" _attr_has_entity_name = True diff --git a/homeassistant/components/simplisafe/lock.py b/homeassistant/components/simplisafe/lock.py index 9e29bb2051b819..a0626898a211cd 100644 --- a/homeassistant/components/simplisafe/lock.py +++ b/homeassistant/components/simplisafe/lock.py @@ -108,7 +108,7 @@ def async_update_from_websocket_event(self, event: WebsocketEvent) -> None: """Update the entity when new data comes from the websocket.""" assert event.event_type - if state := STATE_MAP_FROM_WEBSOCKET_EVENT.get(event.event_type) is not None: + if (state := STATE_MAP_FROM_WEBSOCKET_EVENT.get(event.event_type)) is not None: self._attr_is_locked = state self.async_reset_error_count() else: diff --git a/homeassistant/components/sisyphus/light.py b/homeassistant/components/sisyphus/light.py index eff0fb378a37af..c89d8d11d5421e 100644 --- a/homeassistant/components/sisyphus/light.py +++ b/homeassistant/components/sisyphus/light.py @@ -73,12 +73,12 @@ def name(self): return self._name @property - def is_on(self): + def is_on(self) -> bool: """Return True if the table is on.""" return not self._table.is_sleeping @property - def brightness(self): + def brightness(self) -> int: """Return the current brightness of the table's ring light.""" return self._table.brightness * 255 diff --git a/homeassistant/components/skybeacon/sensor.py b/homeassistant/components/skybeacon/sensor.py index 650e62bc4a1b59..108539c1cef785 100644 --- a/homeassistant/components/skybeacon/sensor.py +++ b/homeassistant/components/skybeacon/sensor.py @@ -59,8 +59,8 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Skybeacon sensor.""" - name = config.get(CONF_NAME) - mac = config.get(CONF_MAC) + name: str = config[CONF_NAME] + mac: str = config[CONF_MAC] _LOGGER.debug("Setting up") mon = Monitor(hass, mac, name) @@ -79,55 +79,37 @@ def monitor_stop(_service_or_event): class SkybeaconHumid(SensorEntity): """Representation of a Skybeacon humidity sensor.""" + _attr_extra_state_attributes = {ATTR_DEVICE: "SKYBEACON", ATTR_MODEL: 1} _attr_native_unit_of_measurement = PERCENTAGE - def __init__(self, name, mon): + def __init__(self, name: str, mon: Monitor) -> None: """Initialize a sensor.""" self.mon = mon - self._name = name - - @property - def name(self): - """Return the name of the sensor.""" - return self._name + self._attr_name = name @property def native_value(self): """Return the state of the device.""" return self.mon.data["humid"] - @property - def extra_state_attributes(self): - """Return the state attributes of the sensor.""" - return {ATTR_DEVICE: "SKYBEACON", ATTR_MODEL: 1} - class SkybeaconTemp(SensorEntity): """Representation of a Skybeacon temperature sensor.""" _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_extra_state_attributes = {ATTR_DEVICE: "SKYBEACON", ATTR_MODEL: 1} _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS - def __init__(self, name, mon): + def __init__(self, name: str, mon: Monitor) -> None: """Initialize a sensor.""" self.mon = mon - self._name = name - - @property - def name(self): - """Return the name of the sensor.""" - return self._name + self._attr_name = name @property def native_value(self): """Return the state of the device.""" return self.mon.data["temp"] - @property - def extra_state_attributes(self): - """Return the state attributes of the sensor.""" - return {ATTR_DEVICE: "SKYBEACON", ATTR_MODEL: 1} - class Monitor(threading.Thread, SensorEntity): """Connection handling.""" diff --git a/homeassistant/components/sleep_as_android/strings.json b/homeassistant/components/sleep_as_android/strings.json index e6678a610d2ba0..173d64e52acf66 100644 --- a/homeassistant/components/sleep_as_android/strings.json +++ b/homeassistant/components/sleep_as_android/strings.json @@ -2,12 +2,17 @@ "config": { "abort": { "cloud_not_connected": "[%key:common::config_flow::abort::cloud_not_connected%]", + "reconfigure_successful": "**Reconfiguration was successful**\n\nIn Sleep as Android go to *Settings → Services → Automation → Webhooks* and update the webhook with the following URL:\n\n`{webhook_url}`", "webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]" }, "create_entry": { "default": "To send events to Home Assistant, you will need to set up a webhook.\n\nOpen Sleep as Android and go to *Settings → Services → Automation → Webhooks*\n\nEnable *Webhooks* and fill in the following webhook in the URL field:\n\n`{webhook_url}`\n\nSee [the documentation]({docs_url}) for further details." }, "step": { + "reconfigure": { + "description": "Are you sure you want to reconfigure the Sleep as Android integration?", + "title": "Reconfigure Sleep as Android" + }, "user": { "description": "Are you sure you want to set up the Sleep as Android integration?", "title": "Set up Sleep as Android" diff --git a/homeassistant/components/sleepiq/__init__.py b/homeassistant/components/sleepiq/__init__.py index 565611fe1692c0..8eb703b7f5f3ee 100644 --- a/homeassistant/components/sleepiq/__init__.py +++ b/homeassistant/components/sleepiq/__init__.py @@ -26,6 +26,7 @@ SleepIQData, SleepIQDataUpdateCoordinator, SleepIQPauseUpdateCoordinator, + SleepIQSleepDataCoordinator, ) _LOGGER = logging.getLogger(__name__) @@ -96,14 +97,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = SleepIQDataUpdateCoordinator(hass, entry, gateway) pause_coordinator = SleepIQPauseUpdateCoordinator(hass, entry, gateway) + sleep_data_coordinator = SleepIQSleepDataCoordinator(hass, entry, gateway) # Call the SleepIQ API to refresh data await coordinator.async_config_entry_first_refresh() 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( data_coordinator=coordinator, pause_coordinator=pause_coordinator, + sleep_data_coordinator=sleep_data_coordinator, client=gateway, ) diff --git a/homeassistant/components/sleepiq/const.py b/homeassistant/components/sleepiq/const.py index 7a9415bac20f13..0efb8e94ebe569 100644 --- a/homeassistant/components/sleepiq/const.py +++ b/homeassistant/components/sleepiq/const.py @@ -15,6 +15,11 @@ SLEEP_NUMBER = "sleep_number" FOOT_WARMING_TIMER = "foot_warming_timer" FOOT_WARMER = "foot_warmer" +SLEEP_SCORE = "sleep_score" +SLEEP_DURATION = "sleep_duration" +HEART_RATE = "heart_rate" +RESPIRATORY_RATE = "respiratory_rate" +HRV = "hrv" ENTITY_TYPES = { ACTUATOR: "Position", CORE_CLIMATE_TIMER: "Core Climate Timer", @@ -25,6 +30,11 @@ SLEEP_NUMBER: "SleepNumber", FOOT_WARMING_TIMER: "Foot Warming Timer", FOOT_WARMER: "Foot Warmer", + SLEEP_SCORE: "Sleep Score", + SLEEP_DURATION: "Sleep Duration", + HEART_RATE: "Heart Rate Average", + RESPIRATORY_RATE: "Respiratory Rate Average", + HRV: "Heart Rate Variability", } LEFT = "left" diff --git a/homeassistant/components/sleepiq/coordinator.py b/homeassistant/components/sleepiq/coordinator.py index 46b754976e58bf..0baeca03fe560d 100644 --- a/homeassistant/components/sleepiq/coordinator.py +++ b/homeassistant/components/sleepiq/coordinator.py @@ -5,17 +5,18 @@ from datetime import timedelta import logging -from asyncsleepiq import AsyncSleepIQ +from asyncsleepiq import AsyncSleepIQ, SleepIQAPIException, SleepIQTimeoutException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed _LOGGER = logging.getLogger(__name__) UPDATE_INTERVAL = timedelta(seconds=60) LONGER_UPDATE_INTERVAL = timedelta(minutes=5) +SLEEP_DATA_UPDATE_INTERVAL = timedelta(hours=1) # Sleep data doesn't change frequently class SleepIQDataUpdateCoordinator(DataUpdateCoordinator[None]): @@ -74,10 +75,48 @@ async def _async_update_data(self) -> None: ) +class SleepIQSleepDataCoordinator(DataUpdateCoordinator[None]): + """SleepIQ sleep health data coordinator.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + client: AsyncSleepIQ, + ) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=f"{config_entry.data[CONF_USERNAME]}@SleepIQSleepData", + update_interval=SLEEP_DATA_UPDATE_INTERVAL, + ) + self.client = client + + async def _async_update_data(self) -> None: + """Fetch sleep health data from API via asyncsleepiq library.""" + try: + await asyncio.gather( + *[ + sleeper.fetch_sleep_data() + for bed in self.client.beds.values() + for sleeper in bed.sleepers + ] + ) + except SleepIQTimeoutException as err: + raise UpdateFailed(f"Timed out fetching SleepIQ sleep data: {err}") from err + except SleepIQAPIException as err: + raise UpdateFailed(f"Failed to fetch SleepIQ sleep data: {err}") from err + + @dataclass class SleepIQData: """Data for the sleepiq integration.""" data_coordinator: SleepIQDataUpdateCoordinator pause_coordinator: SleepIQPauseUpdateCoordinator + sleep_data_coordinator: SleepIQSleepDataCoordinator client: AsyncSleepIQ diff --git a/homeassistant/components/sleepiq/entity.py b/homeassistant/components/sleepiq/entity.py index 829e3a00e6fd6b..49d58b7d5e1a25 100644 --- a/homeassistant/components/sleepiq/entity.py +++ b/homeassistant/components/sleepiq/entity.py @@ -11,9 +11,17 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ENTITY_TYPES, ICON_OCCUPIED -from .coordinator import SleepIQDataUpdateCoordinator, SleepIQPauseUpdateCoordinator - -type _DataCoordinatorType = SleepIQDataUpdateCoordinator | SleepIQPauseUpdateCoordinator +from .coordinator import ( + SleepIQDataUpdateCoordinator, + SleepIQPauseUpdateCoordinator, + SleepIQSleepDataCoordinator, +) + +type _DataCoordinatorType = ( + SleepIQDataUpdateCoordinator + | SleepIQPauseUpdateCoordinator + | SleepIQSleepDataCoordinator +) def device_from_bed(bed: SleepIQBed) -> DeviceInfo: diff --git a/homeassistant/components/sleepiq/icons.json b/homeassistant/components/sleepiq/icons.json new file mode 100644 index 00000000000000..6a3534e325640f --- /dev/null +++ b/homeassistant/components/sleepiq/icons.json @@ -0,0 +1,21 @@ +{ + "entity": { + "sensor": { + "heart_rate_avg": { + "default": "mdi:heart-pulse" + }, + "hrv": { + "default": "mdi:heart-flash" + }, + "respiratory_rate_avg": { + "default": "mdi:lungs" + }, + "sleep_duration": { + "default": "mdi:sleep" + }, + "sleep_score": { + "default": "mdi:sleep" + } + } + } +} diff --git a/homeassistant/components/sleepiq/manifest.json b/homeassistant/components/sleepiq/manifest.json index dd2e05ee3bac1e..39a889997f8f92 100644 --- a/homeassistant/components/sleepiq/manifest.json +++ b/homeassistant/components/sleepiq/manifest.json @@ -9,7 +9,8 @@ } ], "documentation": "https://www.home-assistant.io/integrations/sleepiq", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["asyncsleepiq"], - "requirements": ["asyncsleepiq==1.6.0"] + "requirements": ["asyncsleepiq==1.7.0"] } diff --git a/homeassistant/components/sleepiq/sensor.py b/homeassistant/components/sleepiq/sensor.py index ca4fbc186eddc6..5d22897d97b314 100644 --- a/homeassistant/components/sleepiq/sensor.py +++ b/homeassistant/components/sleepiq/sensor.py @@ -1,19 +1,113 @@ -"""Support for SleepIQ Sensor.""" +"""Support for SleepIQ sensors.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass + from asyncsleepiq import SleepIQBed, SleepIQSleeper -from homeassistant.components.sensor import SensorEntity, SensorStateClass +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + 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, PRESSURE, SLEEP_NUMBER -from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator +from .const import ( + DOMAIN, + HEART_RATE, + HRV, + PRESSURE, + RESPIRATORY_RATE, + SLEEP_DURATION, + SLEEP_NUMBER, + SLEEP_SCORE, +) +from .coordinator import ( + SleepIQData, + SleepIQDataUpdateCoordinator, + SleepIQSleepDataCoordinator, +) from .entity import SleepIQSleeperEntity -SENSORS = [PRESSURE, SLEEP_NUMBER] + +@dataclass(frozen=True, kw_only=True) +class SleepIQSensorEntityDescription(SensorEntityDescription): + """Describes SleepIQ sensor entity.""" + + value_fn: Callable[[SleepIQSleeper], float | int | None] + + +BED_SENSORS: tuple[SleepIQSensorEntityDescription, ...] = ( + SleepIQSensorEntityDescription( + key=PRESSURE, + translation_key="pressure", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda sleeper: sleeper.pressure, + ), + SleepIQSensorEntityDescription( + key=SLEEP_NUMBER, + translation_key="sleep_number", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda sleeper: sleeper.sleep_number, + ), +) + +SLEEP_HEALTH_SENSORS: tuple[SleepIQSensorEntityDescription, ...] = ( + SleepIQSensorEntityDescription( + key=SLEEP_SCORE, + translation_key="sleep_score", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="score", + value_fn=lambda sleeper: ( + sleeper.sleep_data.sleep_score if sleeper.sleep_data else None + ), + ), + SleepIQSensorEntityDescription( + key=SLEEP_DURATION, + translation_key="sleep_duration", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.HOURS, + suggested_display_precision=1, + value_fn=lambda sleeper: ( + round(sleeper.sleep_data.duration / 3600, 1) + if sleeper.sleep_data and sleeper.sleep_data.duration + else None + ), + ), + SleepIQSensorEntityDescription( + key=HEART_RATE, + translation_key="heart_rate_avg", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="bpm", + value_fn=lambda sleeper: ( + sleeper.sleep_data.heart_rate if sleeper.sleep_data else None + ), + ), + SleepIQSensorEntityDescription( + key=RESPIRATORY_RATE, + translation_key="respiratory_rate_avg", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="brpm", + value_fn=lambda sleeper: ( + sleeper.sleep_data.respiratory_rate if sleeper.sleep_data else None + ), + ), + SleepIQSensorEntityDescription( + key=HRV, + translation_key="hrv", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.MILLISECONDS, + value_fn=lambda sleeper: sleeper.sleep_data.hrv if sleeper.sleep_data else None, + ), +) async def async_setup_entry( @@ -23,34 +117,46 @@ async def async_setup_entry( ) -> None: """Set up the SleepIQ bed sensors.""" data: SleepIQData = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - SleepIQSensorEntity(data.data_coordinator, bed, sleeper, sensor_type) + + entities: list[SensorEntity] = [] + + entities.extend( + SleepIQSensorEntity(data.data_coordinator, bed, sleeper, description) + for bed in data.client.beds.values() + for sleeper in bed.sleepers + for description in BED_SENSORS + ) + + entities.extend( + SleepIQSensorEntity(data.sleep_data_coordinator, bed, sleeper, description) for bed in data.client.beds.values() for sleeper in bed.sleepers - for sensor_type in SENSORS + for description in SLEEP_HEALTH_SENSORS ) + async_add_entities(entities) + class SleepIQSensorEntity( - SleepIQSleeperEntity[SleepIQDataUpdateCoordinator], SensorEntity + SleepIQSleeperEntity[SleepIQDataUpdateCoordinator | SleepIQSleepDataCoordinator], + SensorEntity, ): - """Representation of an SleepIQ Entity with CoordinatorEntity.""" + """Representation of a SleepIQ sensor.""" - _attr_icon = "mdi:bed" + entity_description: SleepIQSensorEntityDescription def __init__( self, - coordinator: SleepIQDataUpdateCoordinator, + coordinator: SleepIQDataUpdateCoordinator | SleepIQSleepDataCoordinator, bed: SleepIQBed, sleeper: SleepIQSleeper, - sensor_type: str, + description: SleepIQSensorEntityDescription, ) -> None: """Initialize the sensor.""" - self.sensor_type = sensor_type - self._attr_state_class = SensorStateClass.MEASUREMENT - super().__init__(coordinator, bed, sleeper, sensor_type) + self.entity_description = description + super().__init__(coordinator, bed, sleeper, description.key) @callback def _async_update_attrs(self) -> None: """Update sensor attributes.""" - self._attr_native_value = getattr(self.sleeper, self.sensor_type) + self._attr_native_value = self.entity_description.value_fn(self.sleeper) diff --git a/homeassistant/components/slimproto/manifest.json b/homeassistant/components/slimproto/manifest.json index f270e020740118..4ce170bf078e6c 100644 --- a/homeassistant/components/slimproto/manifest.json +++ b/homeassistant/components/slimproto/manifest.json @@ -5,6 +5,7 @@ "codeowners": ["@marcelveldt"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/slimproto", + "integration_type": "device", "iot_class": "local_push", "requirements": ["aioslimproto==3.0.0"] } diff --git a/homeassistant/components/sma/strings.json b/homeassistant/components/sma/strings.json index 55f2d2512b74d8..8a662a889aa6f9 100644 --- a/homeassistant/components/sma/strings.json +++ b/homeassistant/components/sma/strings.json @@ -38,6 +38,12 @@ "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, + "data_description": { + "group": "[%key:component::sma::config::step::user::data_description::group%]", + "host": "[%key:component::sma::config::step::user::data_description::host%]", + "ssl": "[%key:component::sma::config::step::user::data_description::ssl%]", + "verify_ssl": "[%key:component::sma::config::step::user::data_description::verify_ssl%]" + }, "description": "Use the following form to reconfigure your SMA device.", "title": "Reconfigure SMA Solar Integration" }, @@ -50,7 +56,11 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "host": "The hostname or IP address of your SMA device." + "group": "The group of your SMA device, where the Modbus connection is configured", + "host": "The hostname or IP address of your SMA device", + "password": "The password for your SMA device", + "ssl": "Whether to use SSL to connect to your SMA device. This is required for newer SMA devices, but older devices do not support SSL", + "verify_ssl": "Whether to verify SSL certificates. Disable only if you have a self-signed certificate" }, "description": "Enter your SMA device information.", "title": "Set up SMA Solar" diff --git a/homeassistant/components/smappee/manifest.json b/homeassistant/components/smappee/manifest.json index 0f407d67816606..11255392f908a0 100644 --- a/homeassistant/components/smappee/manifest.json +++ b/homeassistant/components/smappee/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["auth"], "documentation": "https://www.home-assistant.io/integrations/smappee", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["paho_mqtt", "pysmappee"], "requirements": ["pysmappee==0.2.29"], diff --git a/homeassistant/components/smappee/switch.py b/homeassistant/components/smappee/switch.py index cf2ddea5938f8a..c37b51dfe9f438 100644 --- a/homeassistant/components/smappee/switch.py +++ b/homeassistant/components/smappee/switch.py @@ -103,7 +103,7 @@ def name(self): ) @property - def is_on(self): + def is_on(self) -> bool: """Return true if switch is on.""" if self._actuator_type == "INFINITY_OUTPUT_MODULE": return ( diff --git a/homeassistant/components/smarla/__init__.py b/homeassistant/components/smarla/__init__.py index 1dbae0e2346779..82d0313a6e14b1 100644 --- a/homeassistant/components/smarla/__init__.py +++ b/homeassistant/components/smarla/__init__.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import HOST, PLATFORMS @@ -23,16 +23,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: FederwiegeConfigEntry) - # Check if token still has access try: await connection.refresh_token() - except (ConnectionException, AuthenticationException) as e: - raise ConfigEntryError("Invalid authentication") from e + except AuthenticationException as e: + raise ConfigEntryAuthFailed("Invalid authentication") from e + except ConnectionException as e: + raise ConfigEntryNotReady("Unable to connect to server") from e - federwiege = Federwiege(hass.loop, connection) + async def on_auth_failure(): + entry.async_start_reauth(hass) + + federwiege = Federwiege(hass.loop, connection, on_auth_failure) federwiege.register() entry.runtime_data = federwiege await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # Starts a task to keep reconnecting, e.g. when device gets unreachable. + # When an authentication error occurs, it automatically stops and calls + # the on_auth_failure function. federwiege.connect() return True diff --git a/homeassistant/components/smarla/button.py b/homeassistant/components/smarla/button.py new file mode 100644 index 00000000000000..c4ebbf3486c740 --- /dev/null +++ b/homeassistant/components/smarla/button.py @@ -0,0 +1,53 @@ +"""Support for the Swing2Sleep Smarla button entities.""" + +from dataclasses import dataclass + +from pysmarlaapi.federwiege.services.classes import Property + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FederwiegeConfigEntry +from .entity import SmarlaBaseEntity, SmarlaEntityDescription + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class SmarlaButtonEntityDescription(SmarlaEntityDescription, ButtonEntityDescription): + """Class describing Swing2Sleep Smarla button entity.""" + + +BUTTONS: list[SmarlaButtonEntityDescription] = [ + SmarlaButtonEntityDescription( + key="send_diagnostics", + translation_key="send_diagnostics", + service="system", + property="send_diagnostic_data", + entity_category=EntityCategory.CONFIG, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: FederwiegeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Smarla buttons from config entry.""" + federwiege = config_entry.runtime_data + async_add_entities(SmarlaButton(federwiege, desc) for desc in BUTTONS) + + +class SmarlaButton(SmarlaBaseEntity, ButtonEntity): + """Representation of a Smarla button.""" + + entity_description: SmarlaButtonEntityDescription + + _property: Property[str] + + def press(self) -> None: + """Press the button.""" + self._property.set("Sent from Home Assistant") diff --git a/homeassistant/components/smarla/config_flow.py b/homeassistant/components/smarla/config_flow.py index 779add55a07471..30bc24745114bf 100644 --- a/homeassistant/components/smarla/config_flow.py +++ b/homeassistant/components/smarla/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any from pysmarlaapi import Connection @@ -11,12 +12,12 @@ ) import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN from .const import DOMAIN, HOST -STEP_USER_DATA_SCHEMA = vol.Schema({CONF_ACCESS_TOKEN: str}) +STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) class SmarlaConfigFlow(ConfigFlow, domain=DOMAIN): @@ -24,45 +25,89 @@ class SmarlaConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - async def _handle_token(self, token: str) -> tuple[dict[str, str], str | None]: - """Handle the token input.""" - errors: dict[str, str] = {} + def __init__(self) -> None: + """Initialize the config flow.""" + super().__init__() + self.errors: dict[str, str] = {} + async def _handle_token(self, token: str) -> str | None: + """Handle the token input.""" try: conn = Connection(url=HOST, token_b64=token) except ValueError: - errors["base"] = "malformed_token" - return errors, None + self.errors["base"] = "malformed_token" + return None try: await conn.refresh_token() - except ConnectionException, AuthenticationException: - errors["base"] = "invalid_auth" - return errors, None + except ConnectionException: + self.errors["base"] = "cannot_connect" + return None + except AuthenticationException: + self.errors["base"] = "invalid_auth" + return None + + return conn.token.serialNumber + + async def _validate_input( + self, user_input: dict[str, Any] + ) -> dict[str, Any] | None: + """Validate the user input.""" + token = user_input[CONF_ACCESS_TOKEN] + serial_number = await self._handle_token(token=token) + + if serial_number is not None: + await self.async_set_unique_id(serial_number) + + if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch() + else: + self._abort_if_unique_id_configured() - return errors, conn.token.serialNumber + return {"token": token, "serial_number": serial_number} + + return None async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - errors: dict[str, str] = {} - + self.errors = {} if user_input is not None: - raw_token = user_input[CONF_ACCESS_TOKEN] - errors, serial_number = await self._handle_token(token=raw_token) - - if not errors and serial_number is not None: - await self.async_set_unique_id(serial_number) - self._abort_if_unique_id_configured() - + validated_info = await self._validate_input(user_input) + if validated_info is not None: return self.async_create_entry( - title=serial_number, - data={CONF_ACCESS_TOKEN: raw_token}, + title=validated_info["serial_number"], + data={CONF_ACCESS_TOKEN: validated_info["token"]}, ) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, - errors=errors, + errors=self.errors, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauthentication upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauthentication dialog.""" + self.errors = {} + if user_input is not None: + validated_info = await self._validate_input(user_input) + if validated_info is not None: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={CONF_ACCESS_TOKEN: validated_info["token"]}, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_USER_DATA_SCHEMA, + errors=self.errors, ) diff --git a/homeassistant/components/smarla/const.py b/homeassistant/components/smarla/const.py index fcb64f1e3156db..96f814a34cd450 100644 --- a/homeassistant/components/smarla/const.py +++ b/homeassistant/components/smarla/const.py @@ -6,7 +6,13 @@ HOST = "https://devices.swing2sleep.de" -PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.BUTTON, + Platform.NUMBER, + Platform.SENSOR, + Platform.SWITCH, + Platform.UPDATE, +] DEVICE_MODEL_NAME = "Smarla" MANUFACTURER_NAME = "Swing2Sleep" diff --git a/homeassistant/components/smarla/entity.py b/homeassistant/components/smarla/entity.py index ba213adc9ab774..d63b2bc39e1921 100644 --- a/homeassistant/components/smarla/entity.py +++ b/homeassistant/components/smarla/entity.py @@ -1,6 +1,7 @@ """Common base for entities.""" from dataclasses import dataclass +import logging from typing import Any from pysmarlaapi import Federwiege @@ -10,6 +11,8 @@ from .const import DEVICE_MODEL_NAME, DOMAIN, MANUFACTURER_NAME +_LOGGER = logging.getLogger(__name__) + @dataclass(frozen=True, kw_only=True) class SmarlaEntityDescription(EntityDescription): @@ -28,8 +31,9 @@ class SmarlaBaseEntity(Entity): _attr_has_entity_name = True def __init__(self, federwiege: Federwiege, desc: SmarlaEntityDescription) -> None: - """Initialise the entity.""" + """Initialize the entity.""" self.entity_description = desc + self._federwiege = federwiege self._property = federwiege.get_property(desc.service, desc.property) self._attr_unique_id = f"{federwiege.serial_number}-{desc.key}" self._attr_device_info = DeviceInfo( @@ -39,15 +43,35 @@ def __init__(self, federwiege: Federwiege, desc: SmarlaEntityDescription) -> Non manufacturer=MANUFACTURER_NAME, serial_number=federwiege.serial_number, ) + self._unavailable_logged = False + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._federwiege.available + + async def on_availability_change(self, available: bool) -> None: + """Handle availability changes.""" + if not self.available and not self._unavailable_logged: + _LOGGER.info("Entity %s is unavailable", self.entity_id) + self._unavailable_logged = True + elif self.available and self._unavailable_logged: + _LOGGER.info("Entity %s is back online", self.entity_id) + self._unavailable_logged = False + + # Notify ha that state changed + self.async_write_ha_state() - async def on_change(self, value: Any): + async def on_change(self, value: Any) -> None: """Notify ha when state changes.""" self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Run when this Entity has been added to HA.""" + await self._federwiege.add_listener(self.on_availability_change) await self._property.add_listener(self.on_change) async def async_will_remove_from_hass(self) -> None: """Entity being removed from hass.""" await self._property.remove_listener(self.on_change) + await self._federwiege.remove_listener(self.on_availability_change) diff --git a/homeassistant/components/smarla/icons.json b/homeassistant/components/smarla/icons.json index a5d2f8d8deed38..ca67fe8fe40dde 100644 --- a/homeassistant/components/smarla/icons.json +++ b/homeassistant/components/smarla/icons.json @@ -15,8 +15,14 @@ "period": { "default": "mdi:sine-wave" }, + "spring_status": { + "default": "mdi:feather" + }, "swing_count": { "default": "mdi:counter" + }, + "total_swing_time": { + "default": "mdi:history" } }, "switch": { diff --git a/homeassistant/components/smarla/manifest.json b/homeassistant/components/smarla/manifest.json index ef2f3ae8e34e02..75522b1a80e293 100644 --- a/homeassistant/components/smarla/manifest.json +++ b/homeassistant/components/smarla/manifest.json @@ -1,12 +1,12 @@ { "domain": "smarla", "name": "Swing2Sleep Smarla", - "codeowners": ["@explicatis", "@rlint-explicatis"], + "codeowners": ["@explicatis", "@johannes-exp"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/smarla", "integration_type": "device", "iot_class": "cloud_push", "loggers": ["pysmarlaapi", "pysignalr"], - "quality_scale": "bronze", - "requirements": ["pysmarlaapi==1.0.1"] + "quality_scale": "silver", + "requirements": ["pysmarlaapi==1.0.2"] } diff --git a/homeassistant/components/smarla/number.py b/homeassistant/components/smarla/number.py index f6c4cd0df4c847..a50b4e97011d62 100644 --- a/homeassistant/components/smarla/number.py +++ b/homeassistant/components/smarla/number.py @@ -9,6 +9,7 @@ NumberEntityDescription, NumberMode, ) +from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -32,6 +33,7 @@ class SmarlaNumberEntityDescription(SmarlaEntityDescription, NumberEntityDescrip native_max_value=100, native_min_value=0, native_step=1, + native_unit_of_measurement=PERCENTAGE, mode=NumberMode.SLIDER, ), ] diff --git a/homeassistant/components/smarla/quality_scale.yaml b/homeassistant/components/smarla/quality_scale.yaml index 3f85577576c2ed..7753996a280554 100644 --- a/homeassistant/components/smarla/quality_scale.yaml +++ b/homeassistant/components/smarla/quality_scale.yaml @@ -24,11 +24,11 @@ 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: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/smarla/sensor.py b/homeassistant/components/smarla/sensor.py index 9ab1c26548542d..5c90ef227e20c3 100644 --- a/homeassistant/components/smarla/sensor.py +++ b/homeassistant/components/smarla/sensor.py @@ -1,13 +1,18 @@ """Support for the Swing2Sleep Smarla sensor entities.""" +from collections.abc import Callable from dataclasses import dataclass +from typing import Any, Generic, TypeVar from pysmarlaapi.federwiege.services.classes import Property +from pysmarlaapi.federwiege.services.types import SpringStatus from homeassistant.components.sensor import ( + SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, + StateType, ) from homeassistant.const import UnitOfLength, UnitOfTime from homeassistant.core import HomeAssistant @@ -18,50 +23,80 @@ PARALLEL_UPDATES = 0 +_VT = TypeVar("_VT") + @dataclass(frozen=True, kw_only=True) -class SmarlaSensorEntityDescription(SmarlaEntityDescription, SensorEntityDescription): +class SmarlaSensorEntityDescription( + SmarlaEntityDescription, SensorEntityDescription, Generic[_VT] +): """Class describing Swing2Sleep Smarla sensor entities.""" - multiple: bool = False - value_pos: int = 0 + value_fn: Callable[[_VT | None], StateType] = lambda value: ( + value if isinstance(value, (str, int, float)) else None + ) -SENSORS: list[SmarlaSensorEntityDescription] = [ - SmarlaSensorEntityDescription( +SENSORS: list[SmarlaSensorEntityDescription[Any]] = [ + SmarlaSensorEntityDescription[list[int]]( key="amplitude", translation_key="amplitude", service="analyser", property="oscillation", - multiple=True, - value_pos=0, + device_class=SensorDeviceClass.DISTANCE, native_unit_of_measurement=UnitOfLength.MILLIMETERS, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda value: value[0] if value else None, ), - SmarlaSensorEntityDescription( + SmarlaSensorEntityDescription[list[int]]( key="period", translation_key="period", service="analyser", property="oscillation", - multiple=True, - value_pos=1, + device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.MILLISECONDS, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda value: value[1] if value else None, ), - SmarlaSensorEntityDescription( + SmarlaSensorEntityDescription[int]( key="activity", translation_key="activity", service="analyser", property="activity", state_class=SensorStateClass.MEASUREMENT, ), - SmarlaSensorEntityDescription( + SmarlaSensorEntityDescription[int]( key="swing_count", translation_key="swing_count", service="analyser", property="swing_count", state_class=SensorStateClass.TOTAL_INCREASING, ), + SmarlaSensorEntityDescription[int]( + key="total_swing_time", + translation_key="total_swing_time", + service="info", + property="total_swing_time", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SmarlaSensorEntityDescription[SpringStatus]( + key="spring_status", + translation_key="spring_status", + service="analyser", + property="spring_status", + device_class=SensorDeviceClass.ENUM, + options=[ + status.name.lower() + for status in SpringStatus + if status != SpringStatus.UNKNOWN + ], + value_fn=lambda value: ( + value.name.lower() if value and value != SpringStatus.UNKNOWN else None + ), + ), ] @@ -72,38 +107,18 @@ async def async_setup_entry( ) -> None: """Set up the Smarla sensors from config entry.""" federwiege = config_entry.runtime_data - async_add_entities( - ( - SmarlaSensor(federwiege, desc) - if not desc.multiple - else SmarlaSensorMultiple(federwiege, desc) - ) - for desc in SENSORS - ) + async_add_entities(SmarlaSensor(federwiege, desc) for desc in SENSORS) -class SmarlaSensor(SmarlaBaseEntity, SensorEntity): +class SmarlaSensor(SmarlaBaseEntity, SensorEntity, Generic[_VT]): """Representation of Smarla sensor.""" - entity_description: SmarlaSensorEntityDescription - - _property: Property[int] - - @property - def native_value(self) -> int | None: - """Return the entity value to represent the entity state.""" - return self._property.get() - - -class SmarlaSensorMultiple(SmarlaBaseEntity, SensorEntity): - """Representation of Smarla sensor with multiple values inside property.""" - - entity_description: SmarlaSensorEntityDescription + entity_description: SmarlaSensorEntityDescription[_VT] - _property: Property[list[int]] + _property: Property[_VT] @property - def native_value(self) -> int | None: + def native_value(self) -> StateType: """Return the entity value to represent the entity state.""" - v = self._property.get() - return v[self.entity_description.value_pos] if v is not None else None + value = self._property.get() + return self.entity_description.value_fn(value) diff --git a/homeassistant/components/smarla/strings.json b/homeassistant/components/smarla/strings.json index ac74fc671d902a..dc3ea906fd333f 100644 --- a/homeassistant/components/smarla/strings.json +++ b/homeassistant/components/smarla/strings.json @@ -1,13 +1,24 @@ { "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%]", + "unique_id_mismatch": "Please ensure you reconfigure against the same device." }, "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "malformed_token": "Malformed access token" }, "step": { + "reauth_confirm": { + "data": { + "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "access_token": "[%key:component::smarla::config::step::user::data_description::access_token%]" + } + }, "user": { "data": { "access_token": "[%key:common::config_flow::data::access_token%]" @@ -19,6 +30,11 @@ } }, "entity": { + "button": { + "send_diagnostics": { + "name": "Send diagnostics" + } + }, "number": { "intensity": { "name": "Intensity" @@ -34,9 +50,22 @@ "period": { "name": "Period" }, + "spring_status": { + "name": "Spring status", + "state": { + "constellation_critical_too_high": "Critically too strong", + "constellation_critical_too_low": "Critically too weak", + "constellation_too_high": "Too strong", + "constellation_too_low": "Too weak", + "normal": "Normal" + } + }, "swing_count": { "name": "Swing count", "unit_of_measurement": "swings" + }, + "total_swing_time": { + "name": "Total swing time" } }, "switch": { diff --git a/homeassistant/components/smarla/switch.py b/homeassistant/components/smarla/switch.py index 108f4d227c0c63..bcb8211d0faffa 100644 --- a/homeassistant/components/smarla/switch.py +++ b/homeassistant/components/smarla/switch.py @@ -5,7 +5,11 @@ from pysmarlaapi.federwiege.services.classes import Property -from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -26,12 +30,14 @@ class SmarlaSwitchEntityDescription(SmarlaEntityDescription, SwitchEntityDescrip name=None, service="babywiege", property="swing_active", + device_class=SwitchDeviceClass.SWITCH, ), SmarlaSwitchEntityDescription( key="smart_mode", translation_key="smart_mode", service="babywiege", property="smart_mode", + device_class=SwitchDeviceClass.SWITCH, ), ] diff --git a/homeassistant/components/smarla/update.py b/homeassistant/components/smarla/update.py new file mode 100644 index 00000000000000..dee4df7a8b3034 --- /dev/null +++ b/homeassistant/components/smarla/update.py @@ -0,0 +1,110 @@ +"""Swing2Sleep Smarla Update platform.""" + +from dataclasses import dataclass +from datetime import timedelta +from typing import Any + +from pysmarlaapi import Federwiege +from pysmarlaapi.federwiege.services.classes import Property +from pysmarlaapi.federwiege.services.types import UpdateStatus + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FederwiegeConfigEntry +from .entity import SmarlaBaseEntity, SmarlaEntityDescription + +SCAN_INTERVAL = timedelta(seconds=300) +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class SmarlaUpdateEntityDescription(SmarlaEntityDescription, UpdateEntityDescription): + """Class describing Swing2Sleep Smarla update entity.""" + + +UPDATE_ENTITY_DESC = SmarlaUpdateEntityDescription( + key="update", + service="info", + property="version", + device_class=UpdateDeviceClass.FIRMWARE, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: FederwiegeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Smarla update entity based on a config entry.""" + federwiege = config_entry.runtime_data + async_add_entities([SmarlaUpdate(federwiege, UPDATE_ENTITY_DESC)], True) + + +class SmarlaUpdate(SmarlaBaseEntity, UpdateEntity): + """Defines an Smarla update entity.""" + + _attr_supported_features = ( + UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS + ) + _attr_should_poll = True + + entity_description: SmarlaUpdateEntityDescription + + _property: Property[str] + _update_property: Property[int] + _update_status_property: Property[UpdateStatus] + + def __init__( + self, federwiege: Federwiege, desc: SmarlaUpdateEntityDescription + ) -> None: + """Initialize the update entity.""" + super().__init__(federwiege, desc) + self._update_property = federwiege.get_property("system", "firmware_update") + self._update_status_property = federwiege.get_property( + "system", "firmware_update_status" + ) + + async def async_update(self) -> None: + """Check for firmware update and update attributes.""" + value = await self._federwiege.check_firmware_update() + if value is None: + self._attr_latest_version = None + self._attr_release_summary = None + return + + target, notes = value + + self._attr_latest_version = target + self._attr_release_summary = notes + + async def async_added_to_hass(self) -> None: + """Run when this Entity has been added to HA.""" + await super().async_added_to_hass() + await self._update_status_property.add_listener(self.on_change) + + async def async_will_remove_from_hass(self) -> None: + """Entity being removed from hass.""" + await super().async_will_remove_from_hass() + await self._update_status_property.remove_listener(self.on_change) + + @property + def in_progress(self) -> bool | None: + """Return if an update is in progress.""" + status = self._update_status_property.get() + return status not in (None, UpdateStatus.IDLE, UpdateStatus.FAILED) + + @property + def installed_version(self) -> str | None: + """Return the current installed version.""" + return self._property.get() + + def install(self, version: str | None, backup: bool, **kwargs: Any) -> None: + """Install latest update.""" + self._update_property.set(1) diff --git a/homeassistant/components/smart_meter_texas/__init__.py b/homeassistant/components/smart_meter_texas/__init__.py index ce87b85c322c35..d55c44824df6a1 100644 --- a/homeassistant/components/smart_meter_texas/__init__.py +++ b/homeassistant/components/smart_meter_texas/__init__.py @@ -1,30 +1,17 @@ """The Smart Meter Texas integration.""" import logging -import ssl -from smart_meter_texas import Account, Client -from smart_meter_texas.exceptions import ( - SmartMeterTexasAPIError, - SmartMeterTexasAuthError, -) +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 homeassistant.helpers import aiohttp_client -from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util.ssl import get_default_context - -from .const import ( - DATA_COORDINATOR, - DATA_SMART_METER, - DEBOUNCE_COOLDOWN, - DOMAIN, - SCAN_INTERVAL, -) + +from .const import DATA_COORDINATOR, DATA_SMART_METER, DOMAIN +from .coordinator import SmartMeterTexasCoordinator, SmartMeterTexasData _LOGGER = logging.getLogger(__name__) @@ -39,9 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: account = Account(username, password) - ssl_context = get_default_context() - - smart_meter_texas_data = SmartMeterTexasData(hass, entry, account, ssl_context) + smart_meter_texas_data = SmartMeterTexasData(hass, account) try: await smart_meter_texas_data.client.authenticate() except SmartMeterTexasAuthError: @@ -52,26 +37,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await smart_meter_texas_data.setup() - async def async_update_data(): - _LOGGER.debug("Fetching latest data") - await smart_meter_texas_data.read_meters() - return smart_meter_texas_data - # Use a DataUpdateCoordinator to manage the updates. This is due to the # Smart Meter Texas API which takes around 30 seconds to read a meter. # This avoids Home Assistant from complaining about the component taking # too long to update. - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name="Smart Meter Texas", - update_method=async_update_data, - update_interval=SCAN_INTERVAL, - request_refresh_debouncer=Debouncer( - hass, _LOGGER, cooldown=DEBOUNCE_COOLDOWN, immediate=True - ), - ) + coordinator = SmartMeterTexasCoordinator(hass, entry, smart_meter_texas_data) hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = { @@ -88,38 +58,6 @@ async def async_update_data(): return True -class SmartMeterTexasData: - """Manages coordinatation of API data updates.""" - - def __init__( - self, - hass: HomeAssistant, - entry: ConfigEntry, - account: Account, - ssl_context: ssl.SSLContext, - ) -> None: - """Initialize the data coordintator.""" - self._entry = entry - self.account = account - websession = aiohttp_client.async_get_clientsession(hass) - self.client = Client(websession, account, ssl_context=ssl_context) - self.meters: list = [] - - async def setup(self): - """Fetch all of the user's meters.""" - self.meters = await self.account.fetch_meters(self.client) - _LOGGER.debug("Discovered %s meter(s)", len(self.meters)) - - async def read_meters(self): - """Read each meter.""" - for meter in self.meters: - try: - await meter.read_meter(self.client) - except (SmartMeterTexasAPIError, SmartMeterTexasAuthError) as error: - raise UpdateFailed(error) from error - return self.meters - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/smart_meter_texas/coordinator.py b/homeassistant/components/smart_meter_texas/coordinator.py new file mode 100644 index 00000000000000..b489c0db01ed04 --- /dev/null +++ b/homeassistant/components/smart_meter_texas/coordinator.py @@ -0,0 +1,83 @@ +"""DataUpdateCoordinator for the Smart Meter Texas integration.""" + +import logging + +from smart_meter_texas import Account, Client, Meter +from smart_meter_texas.exceptions import ( + SmartMeterTexasAPIError, + SmartMeterTexasAuthError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.ssl import get_default_context + +from .const import DEBOUNCE_COOLDOWN, SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class SmartMeterTexasData: + """Manages coordination of API data updates.""" + + def __init__( + self, + hass: HomeAssistant, + account: Account, + ) -> None: + """Initialize the data coordinator.""" + self.account = account + self.client = Client( + aiohttp_client.async_get_clientsession(hass), + account, + ssl_context=get_default_context(), + ) + self.meters: list[Meter] = [] + + async def setup(self) -> None: + """Fetch all of the user's meters.""" + self.meters = await self.account.fetch_meters(self.client) + _LOGGER.debug("Discovered %s meter(s)", len(self.meters)) + + async def read_meters(self) -> list[Meter]: + """Read each meter.""" + for meter in self.meters: + try: + await meter.read_meter(self.client) + except (SmartMeterTexasAPIError, SmartMeterTexasAuthError) as error: + raise UpdateFailed(error) from error + return self.meters + + +class SmartMeterTexasCoordinator(DataUpdateCoordinator[SmartMeterTexasData]): + """Class to manage fetching Smart Meter Texas data.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + smart_meter_texas_data: SmartMeterTexasData, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name="Smart Meter Texas", + update_interval=SCAN_INTERVAL, + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=DEBOUNCE_COOLDOWN, immediate=True + ), + ) + self._smart_meter_texas_data = smart_meter_texas_data + + async def _async_update_data(self) -> SmartMeterTexasData: + """Fetch latest data.""" + _LOGGER.debug("Fetching latest data") + await self._smart_meter_texas_data.read_meters() + return self._smart_meter_texas_data diff --git a/homeassistant/components/smart_meter_texas/manifest.json b/homeassistant/components/smart_meter_texas/manifest.json index 8bf44fbed152c4..a8397da06795e6 100644 --- a/homeassistant/components/smart_meter_texas/manifest.json +++ b/homeassistant/components/smart_meter_texas/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@grahamwetzler"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/smart_meter_texas", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["smart_meter_texas"], "requirements": ["smart-meter-texas==0.5.5"] diff --git a/homeassistant/components/smart_meter_texas/sensor.py b/homeassistant/components/smart_meter_texas/sensor.py index 6099b489c4387e..ecddd5c80c456d 100644 --- a/homeassistant/components/smart_meter_texas/sensor.py +++ b/homeassistant/components/smart_meter_texas/sensor.py @@ -1,5 +1,7 @@ """Support for Smart Meter Texas sensors.""" +from typing import Any + from smart_meter_texas import Meter from homeassistant.components.sensor import ( @@ -12,10 +14,7 @@ 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, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( DATA_COORDINATOR, @@ -25,6 +24,7 @@ ESIID, METER_NUMBER, ) +from .coordinator import SmartMeterTexasCoordinator async def async_setup_entry( @@ -42,7 +42,9 @@ async def async_setup_entry( # pylint: disable-next=hass-invalid-inheritance # needs fixing -class SmartMeterTexasSensor(CoordinatorEntity, RestoreEntity, SensorEntity): +class SmartMeterTexasSensor( + CoordinatorEntity[SmartMeterTexasCoordinator], RestoreEntity, SensorEntity +): """Representation of an Smart Meter Texas sensor.""" _attr_device_class = SensorDeviceClass.ENERGY @@ -50,7 +52,7 @@ class SmartMeterTexasSensor(CoordinatorEntity, RestoreEntity, SensorEntity): _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR _attr_available = False - def __init__(self, meter: Meter, coordinator: DataUpdateCoordinator) -> None: + def __init__(self, meter: Meter, coordinator: SmartMeterTexasCoordinator) -> None: """Initialize the sensor.""" super().__init__(coordinator) self.meter = meter @@ -58,7 +60,7 @@ def __init__(self, meter: Meter, coordinator: DataUpdateCoordinator) -> None: self._attr_unique_id = f"{meter.esiid}_{meter.meter}" @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the device specific state attributes.""" return { METER_NUMBER: self.meter.meter, diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index ae177162f268a2..47fc16bf879289 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -6,11 +6,9 @@ import contextlib from copy import deepcopy from dataclasses import dataclass -from http import HTTPStatus import logging from typing import TYPE_CHECKING, Any, cast -from aiohttp import ClientResponseError from pysmartthings import ( Attribute, Capability, @@ -46,7 +44,12 @@ Platform, ) from homeassistant.core import Event, HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + OAuth2TokenRequestError, + OAuth2TokenRequestReauthError, +) from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( @@ -71,6 +74,11 @@ _LOGGER = logging.getLogger(__name__) +def format_zigbee_address(address: str) -> str: + """Format a zigbee address to be more readable.""" + return ":".join(address.lower()[i : i + 2] for i in range(0, 16, 2)) + + @dataclass class SmartThingsData: """Define an object to hold SmartThings data.""" @@ -107,6 +115,7 @@ class FullDevice: Platform.SELECT, Platform.SENSOR, Platform.SWITCH, + Platform.TIME, Platform.UPDATE, Platform.VACUUM, Platform.VALVE, @@ -131,9 +140,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) try: await session.async_ensure_token_valid() - except ClientResponseError as err: - if err.status == HTTPStatus.BAD_REQUEST: - raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from err + except OAuth2TokenRequestReauthError as err: + raise ConfigEntryAuthFailed from err + except OAuth2TokenRequestError as err: raise ConfigEntryNotReady from err client = SmartThings(session=async_get_clientsession(hass)) @@ -486,6 +495,14 @@ def create_devices( kwargs[ATTR_CONNECTIONS] = { (dr.CONNECTION_NETWORK_MAC, device.device.hub.mac_address) } + if device.device.hub.hub_eui: + connections = kwargs.setdefault(ATTR_CONNECTIONS, set()) + connections.add( + ( + dr.CONNECTION_ZIGBEE, + format_zigbee_address(device.device.hub.hub_eui), + ) + ) if device.device.parent_device_id and device.device.parent_device_id in devices: kwargs[ATTR_VIA_DEVICE] = (DOMAIN, device.device.parent_device_id) if (ocf := device.device.ocf) is not None: @@ -509,6 +526,10 @@ def create_devices( ATTR_SW_VERSION: viper.software_version, } ) + if (zigbee := device.device.zigbee) is not None: + kwargs[ATTR_CONNECTIONS] = { + (dr.CONNECTION_ZIGBEE, format_zigbee_address(zigbee.eui)) + } if (matter := device.device.matter) is not None: kwargs.update( { @@ -517,19 +538,35 @@ def create_devices( ATTR_SERIAL_NUMBER: matter.serial_number, } ) - if (main_component := device.status.get(MAIN)) is not None and ( - device_identification := main_component.get( - Capability.SAMSUNG_CE_DEVICE_IDENTIFICATION - ) - ) is not None: - new_kwargs = { - ATTR_SERIAL_NUMBER: device_identification[Attribute.SERIAL_NUMBER].value - } - if ATTR_MODEL_ID not in kwargs: - new_kwargs[ATTR_MODEL_ID] = device_identification[ - Attribute.MODEL_NAME - ].value - kwargs.update(new_kwargs) + if (main_component := device.status.get(MAIN)) is not None: + if ( + device_identification := main_component.get( + Capability.SAMSUNG_CE_DEVICE_IDENTIFICATION + ) + ) is not None: + new_kwargs = { + ATTR_SERIAL_NUMBER: device_identification[ + Attribute.SERIAL_NUMBER + ].value + } + if ATTR_MODEL_ID not in kwargs: + new_kwargs[ATTR_MODEL_ID] = device_identification[ + Attribute.MODEL_NAME + ].value + kwargs.update(new_kwargs) + if ( + device_status := main_component.get(Capability.SAMSUNG_IM_DEVICESTATUS) + ) is not None: + mac_connections: set[tuple[str, str]] = set() + status = cast(dict[str, str], device_status[Attribute.STATUS].value) + if wifi_mac := status.get("wifiMac"): + mac_connections.add((dr.CONNECTION_NETWORK_MAC, wifi_mac)) + if bluetooth_address := status.get("btAddr"): + mac_connections.add( + (dr.CONNECTION_BLUETOOTH, bluetooth_address.lower()) + ) + if mac_connections: + kwargs.setdefault(ATTR_CONNECTIONS, set()).update(mac_connections) if ( device_registry.async_get_device({(DOMAIN, device.device.device_id)}) is None @@ -591,7 +628,8 @@ def process_status(status: dict[str, ComponentStatus]) -> dict[str, ComponentSta if "burner" in component: burner_id = int(component.split("-")[-1]) component = f"burner-0{burner_id}" - if component in status: + # Don't delete 'lamp' component even when disabled + if component in status and component != "lamp": del status[component] for component_status in status.values(): process_component_status(component_status) diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index a1599af11af2bd..50739f4a63ff37 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -36,6 +36,7 @@ class SmartThingsBinarySensorEntityDescription(BinarySensorEntityDescription): | None ) = None component_translation_key: dict[str, str] | None = None + supported_states_attributes: Attribute | None = None CAPABILITY_TO_SENSORS: dict[ @@ -188,6 +189,34 @@ class SmartThingsBinarySensorEntityDescription(BinarySensorEntityDescription): }, ) }, + Capability.SAMSUNG_CE_ROBOT_CLEANER_DUST_BAG: { + Attribute.STATUS: SmartThingsBinarySensorEntityDescription( + key=Attribute.STATUS, + is_on_key="full", + component_translation_key={ + "station": "robot_cleaner_dust_bag", + }, + exists_fn=lambda component, _: component == "station", + supported_states_attributes=Attribute.SUPPORTED_STATUS, + ) + }, + Capability.CUSTOM_COOKTOP_OPERATING_STATE: { + Attribute.COOKTOP_OPERATING_STATE: SmartThingsBinarySensorEntityDescription( + key=Attribute.COOKTOP_OPERATING_STATE, + translation_key="cooktop_operating_state", + is_on_key="run", + supported_states_attributes=Attribute.SUPPORTED_COOKTOP_OPERATING_STATE, + ) + }, + Capability.SAMSUNG_CE_MICROFIBER_FILTER_STATUS: { + Attribute.STATUS: SmartThingsBinarySensorEntityDescription( + key=Attribute.STATUS, + translation_key="microfiber_filter_blockage", + is_on_key="blockage", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ) + }, } @@ -237,6 +266,18 @@ async def async_setup_entry( not description.category or get_main_component_category(device) in description.category ) + and ( + not description.supported_states_attributes + or ( + isinstance( + options := device.status[component][capability][ + description.supported_states_attributes + ].value, + list, + ) + and len(options) == 2 + ) + ) ) ) diff --git a/homeassistant/components/smartthings/button.py b/homeassistant/components/smartthings/button.py index bcfbf2cafb391c..61aaeab13f61df 100644 --- a/homeassistant/components/smartthings/button.py +++ b/homeassistant/components/smartthings/button.py @@ -3,16 +3,18 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Any -from pysmartthings import Capability, Command, SmartThings +from pysmartthings import Attribute, Capability, Category, Command, SmartThings from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FullDevice, SmartThingsConfigEntry -from .const import MAIN +from .const import DOMAIN, MAIN from .entity import SmartThingsEntity @@ -22,6 +24,11 @@ class SmartThingsButtonDescription(ButtonEntityDescription): key: Capability command: Command + command_identifier: str | None = None + components: list[str] | None = None + argument: int | str | list[Any] | dict[str, Any] | None = None + requires_remote_control_status: bool = False + requires_dishwasher_machine_state: set[str] | None = None CAPABILITIES_TO_BUTTONS: dict[Capability | str, SmartThingsButtonDescription] = { @@ -42,8 +49,59 @@ class SmartThingsButtonDescription(ButtonEntityDescription): command=Command.RESET_HOOD_FILTER, entity_category=EntityCategory.DIAGNOSTIC, ), + Capability.CUSTOM_HEPA_FILTER: SmartThingsButtonDescription( + key=Capability.CUSTOM_HEPA_FILTER, + translation_key="reset_hepa_filter", + command=Command.RESET_HEPA_FILTER, + entity_category=EntityCategory.DIAGNOSTIC, + components=[MAIN, "station"], + ), +} + + +DISHWASHER_OPERATION_COMMANDS_TO_BUTTONS: dict[ + Command | str, SmartThingsButtonDescription +] = { + Command.CANCEL: SmartThingsButtonDescription( + key=Capability.SAMSUNG_CE_DISHWASHER_OPERATION, + translation_key="cancel", + command_identifier="drain", + command=Command.CANCEL, + argument=[True], + requires_remote_control_status=True, + ), + Command.PAUSE: SmartThingsButtonDescription( + key=Capability.SAMSUNG_CE_DISHWASHER_OPERATION, + translation_key="pause", + command=Command.PAUSE, + requires_remote_control_status=True, + requires_dishwasher_machine_state={"run"}, + ), + Command.RESUME: SmartThingsButtonDescription( + key=Capability.SAMSUNG_CE_DISHWASHER_OPERATION, + translation_key="resume", + command=Command.RESUME, + requires_remote_control_status=True, + requires_dishwasher_machine_state={"pause"}, + ), + Command.START: SmartThingsButtonDescription( + key=Capability.SAMSUNG_CE_DISHWASHER_OPERATION, + translation_key="start", + command=Command.START, + requires_remote_control_status=True, + requires_dishwasher_machine_state={"stop"}, + ), } +DISHWASHER_CANCEL_AND_DRAIN_BUTTON = SmartThingsButtonDescription( + key=Capability.CUSTOM_SUPPORTED_OPTIONS, + translation_key="cancel_and_drain", + command_identifier="89", + command=Command.SET_COURSE, + argument="89", + requires_remote_control_status=True, +) + async def async_setup_entry( hass: HomeAssistant, @@ -52,14 +110,41 @@ async def async_setup_entry( ) -> None: """Add button entities for a config entry.""" entry_data = entry.runtime_data - async_add_entities( + entities: list[SmartThingsEntity] = [] + entities.extend( SmartThingsButtonEntity( - entry_data.client, device, CAPABILITIES_TO_BUTTONS[capability] + entry_data.client, device, description, Capability(capability), component ) + for capability, description in CAPABILITIES_TO_BUTTONS.items() for device in entry_data.devices.values() - for capability in device.status[MAIN] - if capability in CAPABILITIES_TO_BUTTONS + for component in description.components or [MAIN] + if component in device.status and capability in device.status[component] ) + entities.extend( + SmartThingsButtonEntity( + entry_data.client, + device, + description, + Capability.SAMSUNG_CE_DISHWASHER_OPERATION, + ) + for device in entry_data.devices.values() + if Capability.SAMSUNG_CE_DISHWASHER_OPERATION in device.status[MAIN] + for description in DISHWASHER_OPERATION_COMMANDS_TO_BUTTONS.values() + ) + entities.extend( + SmartThingsButtonEntity( + entry_data.client, + device, + DISHWASHER_CANCEL_AND_DRAIN_BUTTON, + Capability.CUSTOM_SUPPORTED_OPTIONS, + ) + for device in entry_data.devices.values() + if ( + device.device.components[MAIN].manufacturer_category == Category.DISHWASHER + and Capability.CUSTOM_SUPPORTED_OPTIONS in device.status[MAIN] + ) + ) + async_add_entities(entities) class SmartThingsButtonEntity(SmartThingsEntity, ButtonEntity): @@ -72,15 +157,53 @@ def __init__( client: SmartThings, device: FullDevice, entity_description: SmartThingsButtonDescription, + capability: Capability, + component: str = MAIN, ) -> None: """Initialize the instance.""" - super().__init__(client, device, set()) + capabilities = set() + if entity_description.requires_remote_control_status: + capabilities.add(Capability.REMOTE_CONTROL_STATUS) + if entity_description.requires_dishwasher_machine_state: + capabilities.add(Capability.DISHWASHER_OPERATING_STATE) + super().__init__(client, device, capabilities) self.entity_description = entity_description - self._attr_unique_id = f"{device.device.device_id}_{MAIN}_{entity_description.key}_{entity_description.command}" + self.button_capability = capability + self._attr_unique_id = f"{device.device.device_id}_{component}_{entity_description.key}_{entity_description.command}" + if entity_description.command_identifier is not None: + self._attr_unique_id += f"_{entity_description.command_identifier}" async def async_press(self) -> None: """Press the button.""" + self._validate_before_execute() await self.execute_device_command( - self.entity_description.key, + self.button_capability, self.entity_description.command, + self.entity_description.argument, ) + + def _validate_before_execute(self) -> None: + """Validate that the command can be executed.""" + if ( + self.entity_description.requires_remote_control_status + and self.get_attribute_value( + Capability.REMOTE_CONTROL_STATUS, Attribute.REMOTE_CONTROL_ENABLED + ) + == "false" + ): + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="remote_control_status" + ) + if ( + self.entity_description.requires_dishwasher_machine_state + and self.get_attribute_value( + Capability.DISHWASHER_OPERATING_STATE, Attribute.MACHINE_STATE + ) + not in self.entity_description.requires_dishwasher_machine_state + ): + state_list = " or ".join( + self.entity_description.requires_dishwasher_machine_state + ) + raise ServiceValidationError( + f"Can only be updated when dishwasher machine state is {state_list}" + ) diff --git a/homeassistant/components/smartthings/icons.json b/homeassistant/components/smartthings/icons.json index 72995be6f698c8..29754f1cbed44d 100644 --- a/homeassistant/components/smartthings/icons.json +++ b/homeassistant/components/smartthings/icons.json @@ -21,12 +21,33 @@ "state": { "on": "mdi:remote" } + }, + "robot_cleaner_dust_bag": { + "default": "mdi:delete" } }, "button": { + "cancel": { + "default": "mdi:stop" + }, + "cancel_and_drain": { + "default": "mdi:stop" + }, + "pause": { + "default": "mdi:pause" + }, + "reset_hepa_filter": { + "default": "mdi:air-filter" + }, "reset_water_filter": { "default": "mdi:reload" }, + "resume": { + "default": "mdi:play" + }, + "start": { + "default": "mdi:play" + }, "stop": { "default": "mdi:stop" } @@ -93,6 +114,18 @@ "stop": "mdi:stop" } }, + "robot_cleaner_cleaning_type": { + "default": "mdi:vacuum" + }, + "robot_cleaner_driving_mode": { + "default": "mdi:car-cog" + }, + "robot_cleaner_sound_mode": { + "default": "mdi:bell-cog" + }, + "robot_cleaner_water_spray_level": { + "default": "mdi:spray-bottle" + }, "selected_zone": { "state": { "all": "mdi:card", @@ -104,6 +137,9 @@ "soil_level": { "default": "mdi:liquid-spot" }, + "sound_detection_sensitivity": { + "default": "mdi:home-sound-in" + }, "spin_level": { "default": "mdi:rotate-right" }, @@ -177,6 +213,12 @@ "on": "mdi:lightbulb-on" } }, + "do_not_disturb": { + "default": "mdi:minus-circle-off", + "state": { + "on": "mdi:minus-circle" + } + }, "dry_plus": { "default": "mdi:heat-wave" }, @@ -213,6 +255,9 @@ "sanitizing_wash": { "default": "mdi:lotion" }, + "sound_detection": { + "default": "mdi:home-sound-in" + }, "sound_effect": { "default": "mdi:volume-high", "state": { @@ -235,6 +280,14 @@ "off": "mdi:tumble-dryer-off" } } + }, + "time": { + "do_not_disturb_end_time": { + "default": "mdi:bell-ring" + }, + "do_not_disturb_start_time": { + "default": "mdi:bell-cancel" + } } } } diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index 1ad315bcd97790..426fb6f9b85beb 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -3,9 +3,18 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from typing import Any, cast -from pysmartthings import Attribute, Capability, Command, DeviceEvent, SmartThings +from pysmartthings import ( + Attribute, + Capability, + Category, + Command, + ComponentStatus, + DeviceEvent, + SmartThings, +) from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -21,6 +30,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, +) from . import FullDevice, SmartThingsConfigEntry from .const import MAIN @@ -32,6 +45,22 @@ Capability.COLOR_TEMPERATURE, ) +LAMP_CAPABILITY_EXISTS: dict[str, Callable[[FullDevice, ComponentStatus], bool]] = { + "lamp": lambda _, __: True, + "hood": lambda device, component: ( + Capability.SAMSUNG_CE_CONNECTION_STATE not in component + or component[Capability.SAMSUNG_CE_CONNECTION_STATE][ + Attribute.CONNECTION_STATE + ].value + != "disconnected" + ), + "cavity-02": lambda _, __: True, + "main": lambda device, component: ( + device.device.components[MAIN].manufacturer_category + in {Category.MICROWAVE, Category.OVEN, Category.RANGE} + ), +} + async def async_setup_entry( hass: HomeAssistant, @@ -40,12 +69,25 @@ async def async_setup_entry( ) -> None: """Add lights for a config entry.""" entry_data = entry.runtime_data - async_add_entities( - SmartThingsLight(entry_data.client, device) + entities: list[LightEntity] = [ + SmartThingsLight(entry_data.client, device, component) + for device in entry_data.devices.values() + for component in device.status + if ( + Capability.SWITCH in device.status[MAIN] + and any(capability in device.status[MAIN] for capability in CAPABILITIES) + and Capability.SAMSUNG_CE_LAMP not in device.status[component] + ) + ] + entities.extend( + SmartThingsLamp(entry_data.client, device, component) for device in entry_data.devices.values() - if Capability.SWITCH in device.status[MAIN] - and any(capability in device.status[MAIN] for capability in CAPABILITIES) + for component, exists_fn in LAMP_CAPABILITY_EXISTS.items() + if component in device.status + and Capability.SAMSUNG_CE_LAMP in device.status[component] + and exists_fn(device, device.status[component]) ) + async_add_entities(entities) def convert_scale( @@ -71,7 +113,9 @@ class SmartThingsLight(SmartThingsEntity, LightEntity, RestoreEntity): # highest kelvin found supported across 20+ handlers. _attr_max_color_temp_kelvin = 9000 # 111 mireds - def __init__(self, client: SmartThings, device: FullDevice) -> None: + def __init__( + self, client: SmartThings, device: FullDevice, component: str = MAIN + ) -> None: """Initialize a SmartThingsLight.""" super().__init__( client, @@ -82,6 +126,7 @@ def __init__(self, client: SmartThings, device: FullDevice) -> None: Capability.SWITCH_LEVEL, Capability.SWITCH, }, + component=component, ) color_modes = set() if self.supports_capability(Capability.COLOR_TEMPERATURE): @@ -236,3 +281,117 @@ def is_on(self) -> bool | None: ) is None: return None return state == "on" + + +class SmartThingsLamp(SmartThingsEntity, LightEntity): + """Define a SmartThings lamp component as a light entity.""" + + _attr_translation_key = "light" + + def __init__( + self, client: SmartThings, device: FullDevice, component: str = MAIN + ) -> None: + """Initialize a SmartThingsLamp.""" + super().__init__( + client, + device, + {Capability.SWITCH, Capability.SAMSUNG_CE_LAMP}, + component=component, + ) + levels = ( + self.get_attribute_value( + Capability.SAMSUNG_CE_LAMP, Attribute.SUPPORTED_BRIGHTNESS_LEVEL + ) + or [] + ) + color_modes = set() + if "off" not in levels or len(levels) > 2: + color_modes.add(ColorMode.BRIGHTNESS) + if not color_modes: + color_modes.add(ColorMode.ONOFF) + self._attr_color_mode = list(color_modes)[0] + self._attr_supported_color_modes = color_modes + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the lamp on.""" + # Switch/brightness/transition + if ATTR_BRIGHTNESS in kwargs: + await self.async_set_level(kwargs[ATTR_BRIGHTNESS]) + return + if self.supports_capability(Capability.SWITCH): + await self.execute_device_command(Capability.SWITCH, Command.ON) + # if no switch, turn on via brightness level + else: + await self.async_set_level(255) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the lamp off.""" + if self.supports_capability(Capability.SWITCH): + await self.execute_device_command(Capability.SWITCH, Command.OFF) + return + await self.execute_device_command( + Capability.SAMSUNG_CE_LAMP, + Command.SET_BRIGHTNESS_LEVEL, + argument="off", + ) + + async def async_set_level(self, brightness: int) -> None: + """Set lamp brightness via supported levels.""" + levels = ( + self.get_attribute_value( + Capability.SAMSUNG_CE_LAMP, Attribute.SUPPORTED_BRIGHTNESS_LEVEL + ) + or [] + ) + # remove 'off' for brightness mapping + if "off" in levels: + levels = [level for level in levels if level != "off"] + level = percentage_to_ordered_list_item( + levels, int(round(brightness * 100 / 255)) + ) + await self.execute_device_command( + Capability.SAMSUNG_CE_LAMP, + Command.SET_BRIGHTNESS_LEVEL, + argument=level, + ) + # turn on switch separately if needed + if ( + self.supports_capability(Capability.SWITCH) + and not self.is_on + and brightness > 0 + ): + await self.execute_device_command(Capability.SWITCH, Command.ON) + + def _update_attr(self) -> None: + """Update lamp-specific attributes.""" + level = self.get_attribute_value( + Capability.SAMSUNG_CE_LAMP, Attribute.BRIGHTNESS_LEVEL + ) + if level is None: + self._attr_brightness = None + return + levels = ( + self.get_attribute_value( + Capability.SAMSUNG_CE_LAMP, Attribute.SUPPORTED_BRIGHTNESS_LEVEL + ) + or [] + ) + if "off" in levels: + if level == "off": + self._attr_brightness = 0 + return + levels = [level for level in levels if level != "off"] + percent = ordered_list_item_to_percentage(levels, level) + self._attr_brightness = int(convert_scale(percent, 100, 255)) + + @property + def is_on(self) -> bool | None: + """Return true if lamp is on.""" + if self.supports_capability(Capability.SWITCH): + state = self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) + if state is None: + return None + return state == "on" + if (brightness := self.brightness) is not None: + return brightness > 0 + return None diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 17aababd641af7..8ec347a5edfc7b 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -24,6 +24,13 @@ { "hostname": "hub*", "macaddress": "286D97*" + }, + { + "hostname": "smarthub", + "macaddress": "683A48*" + }, + { + "hostname": "samsung-*" } ], "documentation": "https://www.home-assistant.io/integrations/smartthings", @@ -31,5 +38,5 @@ "iot_class": "cloud_push", "loggers": ["pysmartthings"], "quality_scale": "bronze", - "requirements": ["pysmartthings==3.5.2"] + "requirements": ["pysmartthings==3.7.2"] } diff --git a/homeassistant/components/smartthings/select.py b/homeassistant/components/smartthings/select.py index 063c6b591acc99..b91cd641080f3b 100644 --- a/homeassistant/components/smartthings/select.py +++ b/homeassistant/components/smartthings/select.py @@ -26,6 +26,25 @@ "off": "off", } +SOUND_MODE_TO_HA = { + "voice": "voice", + "beep": "tone", + "mute": "mute", +} + +DRIVING_MODE_TO_HA = { + "areaThenWalls": "area_then_walls", + "wallFirst": "walls_first", + "quickCleaningZigzagPattern": "quick_clean_zigzag_pattern", +} + +CLEANING_TYPE_TO_HA = { + "vacuum": "vacuum", + "mop": "mop", + "vacuumAndMopTogether": "vacuum_and_mop_together", + "mopAfterVacuum": "mop_after_vacuum", +} + WASHER_SOIL_LEVEL_TO_HA = { "none": "none", "heavy": "heavy", @@ -37,6 +56,14 @@ "down": "down", } +WATER_SPRAY_LEVEL_TO_HA = { + "high": "high", + "mediumHigh": "moderate_high", + "medium": "medium", + "mediumLow": "moderate_low", + "low": "low", +} + WASHER_SPIN_LEVEL_TO_HA = { "none": "none", "rinseHold": "rinse_hold", @@ -159,6 +186,15 @@ class SmartThingsSelectDescription(SelectEntityDescription): extra_components=["hood"], capability_ignore_list=[Capability.SAMSUNG_CE_CONNECTION_STATE], ), + Capability.SAMSUNG_CE_SOUND_DETECTION_SENSITIVITY: SmartThingsSelectDescription( + key=Capability.SAMSUNG_CE_SOUND_DETECTION_SENSITIVITY, + translation_key="sound_detection_sensitivity", + options_attribute=Attribute.SUPPORTED_LEVELS, + status_attribute=Attribute.LEVEL, + command=Command.SET_LEVEL, + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ), Capability.CUSTOM_WASHER_SPIN_LEVEL: SmartThingsSelectDescription( key=Capability.CUSTOM_WASHER_SPIN_LEVEL, translation_key="spin_level", @@ -187,6 +223,24 @@ class SmartThingsSelectDescription(SelectEntityDescription): options_map=WASHER_WATER_TEMPERATURE_TO_HA, entity_category=EntityCategory.CONFIG, ), + Capability.SAMSUNG_CE_ROBOT_CLEANER_WATER_SPRAY_LEVEL: SmartThingsSelectDescription( + key=Capability.SAMSUNG_CE_ROBOT_CLEANER_WATER_SPRAY_LEVEL, + translation_key="robot_cleaner_water_spray_level", + options_attribute=Attribute.SUPPORTED_WATER_SPRAY_LEVELS, + status_attribute=Attribute.WATER_SPRAY_LEVEL, + command=Command.SET_WATER_SPRAY_LEVEL, + options_map=WATER_SPRAY_LEVEL_TO_HA, + entity_category=EntityCategory.CONFIG, + ), + Capability.SAMSUNG_CE_ROBOT_CLEANER_DRIVING_MODE: SmartThingsSelectDescription( + key=Capability.SAMSUNG_CE_ROBOT_CLEANER_DRIVING_MODE, + translation_key="robot_cleaner_driving_mode", + options_attribute=Attribute.SUPPORTED_DRIVING_MODES, + status_attribute=Attribute.DRIVING_MODE, + command=Command.SET_DRIVING_MODE, + options_map=DRIVING_MODE_TO_HA, + entity_category=EntityCategory.CONFIG, + ), Capability.SAMSUNG_CE_DUST_FILTER_ALARM: SmartThingsSelectDescription( key=Capability.SAMSUNG_CE_DUST_FILTER_ALARM, translation_key="dust_filter_alarm", @@ -196,6 +250,25 @@ class SmartThingsSelectDescription(SelectEntityDescription): entity_category=EntityCategory.CONFIG, value_is_integer=True, ), + Capability.SAMSUNG_CE_ROBOT_CLEANER_SYSTEM_SOUND_MODE: SmartThingsSelectDescription( + key=Capability.SAMSUNG_CE_ROBOT_CLEANER_SYSTEM_SOUND_MODE, + translation_key="robot_cleaner_sound_mode", + options_attribute=Attribute.SUPPORTED_SOUND_MODES, + status_attribute=Attribute.SOUND_MODE, + command=Command.SET_SOUND_MODE, + options_map=SOUND_MODE_TO_HA, + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ), + Capability.SAMSUNG_CE_ROBOT_CLEANER_CLEANING_TYPE: SmartThingsSelectDescription( + key=Capability.SAMSUNG_CE_ROBOT_CLEANER_CLEANING_TYPE, + translation_key="robot_cleaner_cleaning_type", + options_attribute=Attribute.SUPPORTED_CLEANING_TYPES, + status_attribute=Attribute.CLEANING_TYPE, + command=Command.SET_CLEANING_TYPE, + options_map=CLEANING_TYPE_TO_HA, + entity_category=EntityCategory.CONFIG, + ), } DISHWASHER_WASHING_OPTIONS_TO_SELECT: dict[ Attribute | str, SmartThingsSelectDescription diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 0282fb9ca3da10..085598ba416f54 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -95,6 +95,7 @@ ROBOT_CLEANER_MOVEMENT_MAP = { "powerOff": "off", + "washingMop": "washing_mop", } OVEN_MODE = { @@ -161,6 +162,13 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): use_temperature_unit: bool = False deprecated: Callable[[ComponentStatus], tuple[str, str] | None] | None = None component_translation_key: dict[str, str] | None = None + presentation_fn: ( + Callable[ + [str | None, str | float | int | datetime | None], + str | float | int | datetime | None, + ] + | None + ) = None CAPABILITY_TO_SENSORS: dict[ @@ -762,6 +770,13 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): (value := cast(dict | None, status.value)) is not None and "power" in value ), + presentation_fn=lambda presentation_id, value: ( + value * 1000 + if presentation_id is not None + and "EHS" in presentation_id + and isinstance(value, (int, float)) + else value + ), ), SmartThingsSensorEntityDescription( key="deltaEnergy_meter", @@ -880,6 +895,7 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): "after", "cleaning", "pause", + "washing_mop", ], device_class=SensorDeviceClass.ENUM, value_fn=lambda value: ROBOT_CLEANER_MOVEMENT_MAP.get(value, value), @@ -1205,6 +1221,24 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): ) ] }, + Capability.SAMSUNG_CE_MICROFIBER_FILTER_OPERATING_STATE: { + Attribute.MICROFIBER_FILTER_JOB_STATE: [ + SmartThingsSensorEntityDescription( + key=Attribute.MICROFIBER_FILTER_JOB_STATE, + translation_key="microfiber_filter_job_state", + device_class=SensorDeviceClass.ENUM, + options_attribute=Attribute.SUPPORTED_JOB_STATES, + ) + ], + Attribute.OPERATING_STATE: [ + SmartThingsSensorEntityDescription( + key=Attribute.OPERATING_STATE, + translation_key="microfiber_filter_operating_state", + device_class=SensorDeviceClass.ENUM, + options_attribute=Attribute.SUPPORTED_OPERATING_STATES, + ) + ], + }, } @@ -1345,7 +1379,12 @@ def native_value(self) -> str | float | datetime | int | None: res = self.get_attribute_value(self.capability, self._attribute) if options_map := self.entity_description.options_map: return options_map.get(res) - return self.entity_description.value_fn(res) + value = self.entity_description.value_fn(res) + if self.entity_description.presentation_fn: + value = self.entity_description.presentation_fn( + self.device.device.presentation_id, value + ) + return value @property def native_unit_of_measurement(self) -> str | None: diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 6a1d69ec866243..935dab58ca9575 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -23,6 +23,9 @@ "user": "[%key:common::config_flow::initiate_flow::account%]" }, "step": { + "oauth_discovery": { + "description": "Home Assistant has found a SmartThings device on your network. Press **Submit** to continue setting up SmartThings." + }, "pick_implementation": { "data": { "implementation": "[%key:common::config_flow::data::implementation%]" @@ -46,6 +49,9 @@ "child_lock": { "name": "Child lock" }, + "cooktop_operating_state": { + "name": "[%key:component::smartthings::entity::sensor::cooktop_operating_state::name%]" + }, "cool_select_plus_door": { "name": "CoolSelect+ door" }, @@ -67,12 +73,18 @@ "keep_fresh_mode_active": { "name": "Keep fresh mode active" }, + "microfiber_filter_blockage": { + "name": "Filter blockage" + }, "oven_cavity_status": { "name": "Second cavity status" }, "remote_control": { "name": "Remote control" }, + "robot_cleaner_dust_bag": { + "name": "Dust bag full" + }, "sub_remote_control": { "name": "Upper washer remote control" }, @@ -81,12 +93,30 @@ } }, "button": { + "cancel": { + "name": "Cancel" + }, + "cancel_and_drain": { + "name": "Cancel and drain" + }, + "pause": { + "name": "[%key:common::action::pause%]" + }, + "reset_hepa_filter": { + "name": "Reset HEPA filter" + }, "reset_hood_filter": { "name": "Reset filter" }, "reset_water_filter": { "name": "Reset water filter" }, + "resume": { + "name": "Resume" + }, + "start": { + "name": "[%key:common::action::start%]" + }, "stop": { "name": "[%key:common::action::stop%]" } @@ -156,6 +186,11 @@ } } }, + "light": { + "light": { + "name": "[%key:component::light::title%]" + } + }, "number": { "cool_select_plus_temperature": { "name": "CoolSelect+ temperature" @@ -220,6 +255,41 @@ "stop": "[%key:common::state::stopped%]" } }, + "robot_cleaner_cleaning_type": { + "name": "Cleaning type", + "state": { + "mop": "Mop", + "mop_after_vacuum": "Mop after vacuuming", + "vacuum": "Vacuum", + "vacuum_and_mop_together": "Vacuum and mop together" + } + }, + "robot_cleaner_driving_mode": { + "name": "Driving mode", + "state": { + "area_then_walls": "Area then walls", + "quick_clean_zigzag_pattern": "Quick clean in a zigzag pattern", + "walls_first": "Walls first" + } + }, + "robot_cleaner_sound_mode": { + "name": "Sound mode", + "state": { + "mute": "Mute", + "tone": "Tone", + "voice": "Voice" + } + }, + "robot_cleaner_water_spray_level": { + "name": "Water level", + "state": { + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "moderate_high": "Moderate high", + "moderate_low": "Moderate low" + } + }, "selected_zone": { "name": "Selected zone", "state": { @@ -242,6 +312,14 @@ "up": "Up" } }, + "sound_detection_sensitivity": { + "name": "Sound detection sensitivity", + "state": { + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]" + } + }, "spin_level": { "name": "Spin level", "state": { @@ -507,6 +585,25 @@ "media_playback_status": { "name": "Media playback status" }, + "microfiber_filter_job_state": { + "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]", + "state": { + "bypassing": "Bypassing", + "filtering": "Filtering", + "none": "[%key:component::smartthings::entity::sensor::washer_job_state::state::none%]", + "sensing": "Weight sensing", + "stopping": "Stopping", + "waiting": "Waiting" + } + }, + "microfiber_filter_operating_state": { + "name": "[%key:component::smartthings::entity::sensor::cooktop_operating_state::name%]", + "state": { + "paused": "[%key:common::state::paused%]", + "ready": "[%key:component::smartthings::entity::sensor::oven_machine_state::state::ready%]", + "running": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]" + } + }, "odor_sensor": { "name": "Odor sensor" }, @@ -715,7 +812,8 @@ "off": "[%key:common::state::off%]", "pause": "[%key:common::state::paused%]", "point": "Point", - "reserve": "Reserve" + "reserve": "Reserve", + "washing_mop": "Washing mop" } }, "robot_cleaner_turbo_mode": { @@ -852,9 +950,15 @@ "bubble_soak": { "name": "Bubble Soak" }, + "bypass_mode": { + "name": "Bypass mode" + }, "display_lighting": { "name": "Display lighting" }, + "do_not_disturb": { + "name": "Do not disturb" + }, "dry_plus": { "name": "Dry plus" }, @@ -897,6 +1001,9 @@ "sanitizing_wash": { "name": "Sanitizing wash" }, + "sound_detection": { + "name": "Sound detection" + }, "sound_effect": { "name": "Sound effect" }, @@ -912,11 +1019,36 @@ "wrinkle_prevent": { "name": "Wrinkle prevent" } + }, + "time": { + "do_not_disturb_end_time": { + "name": "Do not disturb end time" + }, + "do_not_disturb_start_time": { + "name": "Do not disturb start time" + } + }, + "vacuum": { + "vacuum": { + "state_attributes": { + "fan_speed": { + "state": { + "maximum": "Maximum", + "normal": "Normal", + "quiet": "Quiet", + "smart": "Smart" + } + } + } + } } }, "exceptions": { "oauth2_implementation_unavailable": { "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + }, + "remote_control_status": { + "message": "Can only be changed when remote control is enabled" } }, "issues": { diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index 682d6f80493efe..ac02d131145008 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -101,6 +101,15 @@ class SmartThingsDishwasherWashingOptionSwitchEntityDescription( command=Command.SET_STEAM_CLOSET_AUTO_CYCLE_LINK, entity_category=EntityCategory.CONFIG, ), + Capability.SAMSUNG_CE_MICROFIBER_FILTER_SETTINGS: SmartThingsCommandSwitchEntityDescription( + key=Capability.SAMSUNG_CE_MICROFIBER_FILTER_SETTINGS, + translation_key="bypass_mode", + status_attribute=Attribute.BYPASS_MODE, + entity_category=EntityCategory.CONFIG, + on_key="enabled", + off_key="disabled", + command=Command.SET_BYPASS_MODE, + ), } CAPABILITY_TO_SWITCHES: dict[Capability | str, SmartThingsSwitchEntityDescription] = { Capability.SAMSUNG_CE_AIR_CONDITIONER_BEEP: SmartThingsSwitchEntityDescription( @@ -162,6 +171,23 @@ class SmartThingsDishwasherWashingOptionSwitchEntityDescription( status_attribute=Attribute.STATUS, entity_category=EntityCategory.CONFIG, ), + Capability.CUSTOM_DO_NOT_DISTURB_MODE: SmartThingsSwitchEntityDescription( + key=Capability.CUSTOM_DO_NOT_DISTURB_MODE, + translation_key="do_not_disturb", + status_attribute=Attribute.DO_NOT_DISTURB, + entity_category=EntityCategory.CONFIG, + on_command=Command.DO_NOT_DISTURB_ON, + off_command=Command.DO_NOT_DISTURB_OFF, + ), + Capability.SOUND_DETECTION: SmartThingsSwitchEntityDescription( + key=Capability.SOUND_DETECTION, + translation_key="sound_detection", + status_attribute=Attribute.SOUND_DETECTION_STATE, + entity_category=EntityCategory.CONFIG, + on_key="enabled", + on_command=Command.ENABLE_SOUND_DETECTION, + off_command=Command.DISABLE_SOUND_DETECTION, + ), } DISHWASHER_WASHING_OPTIONS_TO_SWITCHES: dict[ Attribute | str, SmartThingsDishwasherWashingOptionSwitchEntityDescription diff --git a/homeassistant/components/smartthings/time.py b/homeassistant/components/smartthings/time.py new file mode 100644 index 00000000000000..de4057d4ac1e60 --- /dev/null +++ b/homeassistant/components/smartthings/time.py @@ -0,0 +1,102 @@ +"""Time platform for SmartThings.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import time + +from pysmartthings import Attribute, Capability, Command, SmartThings + +from homeassistant.components.time import TimeEntity, TimeEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN +from .entity import SmartThingsEntity + + +@dataclass(frozen=True, kw_only=True) +class SmartThingsTimeEntityDescription(TimeEntityDescription): + """Describe a SmartThings time entity.""" + + attribute: Attribute + + +DND_ENTITIES = [ + SmartThingsTimeEntityDescription( + key=Attribute.START_TIME, + translation_key="do_not_disturb_start_time", + attribute=Attribute.START_TIME, + entity_category=EntityCategory.CONFIG, + ), + SmartThingsTimeEntityDescription( + key=Attribute.END_TIME, + translation_key="do_not_disturb_end_time", + attribute=Attribute.END_TIME, + entity_category=EntityCategory.CONFIG, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add time entities for a config entry.""" + entry_data = entry.runtime_data + async_add_entities( + SmartThingsDnDTime(entry_data.client, device, description) + for device in entry_data.devices.values() + if Capability.CUSTOM_DO_NOT_DISTURB_MODE in device.status.get(MAIN, {}) + for description in DND_ENTITIES + ) + + +class SmartThingsDnDTime(SmartThingsEntity, TimeEntity): + """Define a SmartThings time entity.""" + + entity_description: SmartThingsTimeEntityDescription + + def __init__( + self, + client: SmartThings, + device: FullDevice, + entity_description: SmartThingsTimeEntityDescription, + ) -> None: + """Initialize the time entity.""" + super().__init__(client, device, {Capability.CUSTOM_DO_NOT_DISTURB_MODE}) + self.entity_description = entity_description + self._attr_unique_id = f"{device.device.device_id}_{MAIN}_{Capability.CUSTOM_DO_NOT_DISTURB_MODE}_{entity_description.attribute}_{entity_description.attribute}" + + async def async_set_value(self, value: time) -> None: + """Set the time value.""" + payload = { + "mode": self.get_attribute_value( + Capability.CUSTOM_DO_NOT_DISTURB_MODE, Attribute.DO_NOT_DISTURB + ), + "startTime": self.get_attribute_value( + Capability.CUSTOM_DO_NOT_DISTURB_MODE, Attribute.START_TIME + ), + "endTime": self.get_attribute_value( + Capability.CUSTOM_DO_NOT_DISTURB_MODE, Attribute.END_TIME + ), + } + await self.execute_device_command( + Capability.CUSTOM_DO_NOT_DISTURB_MODE, + Command.SET_DO_NOT_DISTURB_MODE, + { + **payload, + self.entity_description.attribute: f"{value.hour:02d}{value.minute:02d}", + }, + ) + + @property + def native_value(self) -> time: + """Return the time value.""" + state = self.get_attribute_value( + Capability.CUSTOM_DO_NOT_DISTURB_MODE, self.entity_description.attribute + ) + return time(int(state[:2]), int(state[3:5])) diff --git a/homeassistant/components/smartthings/vacuum.py b/homeassistant/components/smartthings/vacuum.py index 5915284215014e..6c7fe681b9514e 100644 --- a/homeassistant/components/smartthings/vacuum.py +++ b/homeassistant/components/smartthings/vacuum.py @@ -22,6 +22,15 @@ _LOGGER = logging.getLogger(__name__) +TURBO_MODE_TO_FAN_SPEED = { + "silence": "normal", + "on": "maximum", + "off": "smart", + "extraSilence": "quiet", +} + +FAN_SPEED_TO_TURBO_MODE = {v: k for k, v in TURBO_MODE_TO_FAN_SPEED.items()} + async def async_setup_entry( hass: HomeAssistant, @@ -41,20 +50,26 @@ class SamsungJetBotVacuum(SmartThingsEntity, StateVacuumEntity): """Representation of a Vacuum.""" _attr_name = None - _attr_supported_features = ( - VacuumEntityFeature.START - | VacuumEntityFeature.RETURN_HOME - | VacuumEntityFeature.PAUSE - | VacuumEntityFeature.STATE - ) + _attr_translation_key = "vacuum" def __init__(self, client: SmartThings, device: FullDevice) -> None: """Initialize the Samsung robot cleaner vacuum entity.""" super().__init__( client, device, - {Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE}, + { + Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE, + Capability.ROBOT_CLEANER_TURBO_MODE, + }, ) + self._attr_supported_features = ( + VacuumEntityFeature.START + | VacuumEntityFeature.RETURN_HOME + | VacuumEntityFeature.PAUSE + | VacuumEntityFeature.STATE + ) + if self.supports_capability(Capability.ROBOT_CLEANER_TURBO_MODE): + self._attr_supported_features |= VacuumEntityFeature.FAN_SPEED @property def activity(self) -> VacuumActivity | None: @@ -74,6 +89,23 @@ def activity(self) -> VacuumActivity | None: "charging": VacuumActivity.DOCKED, }.get(status) + @property + def fan_speed_list(self) -> list[str]: + """Return the list of available fan speeds.""" + if not self.supports_capability(Capability.ROBOT_CLEANER_TURBO_MODE): + return [] + return list(TURBO_MODE_TO_FAN_SPEED.values()) + + @property + def fan_speed(self) -> str | None: + """Return the current fan speed.""" + if not self.supports_capability(Capability.ROBOT_CLEANER_TURBO_MODE): + return None + turbo_mode = self.get_attribute_value( + Capability.ROBOT_CLEANER_TURBO_MODE, Attribute.ROBOT_CLEANER_TURBO_MODE + ) + return TURBO_MODE_TO_FAN_SPEED.get(turbo_mode) + async def async_start(self) -> None: """Start the vacuum's operation.""" await self.execute_device_command( @@ -93,3 +125,12 @@ async def async_return_to_base(self, **kwargs: Any) -> None: Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE, Command.RETURN_TO_HOME, ) + + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: + """Set the fan speed.""" + turbo_mode = FAN_SPEED_TO_TURBO_MODE[fan_speed] + await self.execute_device_command( + Capability.ROBOT_CLEANER_TURBO_MODE, + Command.SET_ROBOT_CLEANER_TURBO_MODE, + turbo_mode, + ) diff --git a/homeassistant/components/smarttub/__init__.py b/homeassistant/components/smarttub/__init__.py index 178fd9a70e2f98..2585a3554d4961 100644 --- a/homeassistant/components/smarttub/__init__.py +++ b/homeassistant/components/smarttub/__init__.py @@ -19,8 +19,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartTubConfigEntry) -> controller = SmartTubController(hass) - if not await controller.async_setup_entry(entry): - return False + await controller.async_setup_entry(entry) entry.runtime_data = controller diff --git a/homeassistant/components/smarttub/binary_sensor.py b/homeassistant/components/smarttub/binary_sensor.py index e92f01f4a97b76..d3ce8a1461c366 100644 --- a/homeassistant/components/smarttub/binary_sensor.py +++ b/homeassistant/components/smarttub/binary_sensor.py @@ -100,6 +100,7 @@ class SmartTubOnline(SmartTubOnboardSensorBase, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY # This seems to be very noisy and not generally useful, so disable by default. _attr_entity_registry_enabled_default = False + _attr_translation_key = "online" def __init__( self, coordinator: DataUpdateCoordinator[dict[str, Any]], spa: Spa @@ -117,6 +118,7 @@ class SmartTubReminder(SmartTubEntity, BinarySensorEntity): """Reminders for maintenance actions.""" _attr_device_class = BinarySensorDeviceClass.PROBLEM + _attr_translation_key = "reminder" def __init__( self, @@ -132,6 +134,9 @@ def __init__( ) self.reminder_id = reminder.id self._attr_unique_id = f"{spa.id}-reminder-{reminder.id}" + self._attr_translation_placeholders = { + "reminder_name": reminder.name.title(), + } @property def reminder(self) -> SpaReminder: @@ -169,6 +174,7 @@ class SmartTubError(SmartTubEntity, BinarySensorEntity): """ _attr_device_class = BinarySensorDeviceClass.PROBLEM + _attr_translation_key = "error" def __init__( self, coordinator: DataUpdateCoordinator[dict[str, Any]], spa: Spa @@ -213,6 +219,7 @@ class SmartTubCoverSensor(SmartTubExternalSensorBase, BinarySensorEntity): """Wireless magnetic cover sensor.""" _attr_device_class = BinarySensorDeviceClass.OPENING + _attr_translation_key = "cover_sensor" @property def is_on(self) -> bool: diff --git a/homeassistant/components/smarttub/climate.py b/homeassistant/components/smarttub/climate.py index 41af5543f25da1..3e533d4a0514e9 100644 --- a/homeassistant/components/smarttub/climate.py +++ b/homeassistant/components/smarttub/climate.py @@ -74,6 +74,7 @@ class SmartTubThermostat(SmartTubEntity, ClimateEntity): _attr_min_temp = DEFAULT_MIN_TEMP _attr_max_temp = DEFAULT_MAX_TEMP _attr_preset_modes = list(PRESET_MODES.values()) + _attr_translation_key = "thermostat" def __init__( self, coordinator: DataUpdateCoordinator[dict[str, Any]], spa: Spa @@ -101,12 +102,12 @@ def preset_mode(self) -> str: return PRESET_MODES[self.spa_status.heat_mode] @property - def current_temperature(self): + def current_temperature(self) -> float | None: """Return the current water temperature.""" return self.spa_status.water.temperature @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the target water temperature.""" return self.spa_status.set_temperature diff --git a/homeassistant/components/smarttub/entity.py b/homeassistant/components/smarttub/entity.py index 53562fd887aff2..0a364ce3cbd3e0 100644 --- a/homeassistant/components/smarttub/entity.py +++ b/homeassistant/components/smarttub/entity.py @@ -17,6 +17,8 @@ class SmartTubEntity(CoordinatorEntity): """Base class for SmartTub entities.""" + _attr_has_entity_name = True + def __init__( self, coordinator: DataUpdateCoordinator[dict[str, Any]], @@ -36,9 +38,8 @@ def __init__( identifiers={(DOMAIN, spa.id)}, manufacturer=spa.brand, model=spa.model, + name=get_spa_name(spa), ) - spa_name = get_spa_name(self.spa) - self._attr_name = f"{spa_name} {entity_name}" @property def spa_status(self) -> SpaState: @@ -70,6 +71,8 @@ def _state(self): class SmartTubExternalSensorBase(SmartTubEntity): """Class for additional BLE wireless sensors sold separately.""" + _attr_translation_key = "external_sensor" + def __init__( self, coordinator: DataUpdateCoordinator[dict[str, Any]], @@ -77,12 +80,21 @@ def __init__( sensor: SpaSensor, ) -> None: """Initialize the external sensor entity.""" + super().__init__(coordinator, spa, self._sensor_key(sensor)) self.sensor_address = sensor.address self._attr_unique_id = f"{spa.id}-externalsensor-{sensor.address}" - super().__init__(coordinator, spa, self._human_readable_name(sensor)) + self._attr_translation_placeholders = { + "sensor_name": self._human_readable_name(sensor), + } + + @staticmethod + def _sensor_key(sensor: SpaSensor) -> str: + """Return a key for the sensor suitable for unique_id generation.""" + return sensor.name.strip("{}").replace("-", "_") @staticmethod def _human_readable_name(sensor: SpaSensor) -> str: + """Return a human-readable name for the sensor.""" return " ".join( word.capitalize() for word in sensor.name.strip("{}").split("-") ) diff --git a/homeassistant/components/smarttub/light.py b/homeassistant/components/smarttub/light.py index f39757b4ae7777..a3fc7adf1a96de 100644 --- a/homeassistant/components/smarttub/light.py +++ b/homeassistant/components/smarttub/light.py @@ -19,7 +19,6 @@ from .const import ATTR_LIGHTS, DEFAULT_LIGHT_BRIGHTNESS, DEFAULT_LIGHT_EFFECT from .controller import SmartTubConfigEntry from .entity import SmartTubEntity -from .helpers import get_spa_name PARALLEL_UPDATES = 0 @@ -56,8 +55,8 @@ def __init__( super().__init__(coordinator, light.spa, "light") self.light_zone = light.zone self._attr_unique_id = f"{super().unique_id}-{light.zone}" - spa_name = get_spa_name(self.spa) - self._attr_name = f"{spa_name} Light {light.zone}" + self._attr_translation_key = "light_zone" + self._attr_translation_placeholders = {"zone": str(light.zone)} @property def light(self) -> SpaLight: @@ -65,7 +64,7 @@ def light(self) -> SpaLight: return self.coordinator.data[self.spa.id][ATTR_LIGHTS][self.light_zone] @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" # SmartTub intensity is 0..100 @@ -82,12 +81,12 @@ def _hass_to_smarttub_brightness(brightness): return round(brightness * 100 / 255) @property - def is_on(self): + def is_on(self) -> bool: """Return true if the light is on.""" return self.light.mode != SpaLight.LightMode.OFF @property - def effect(self): + def effect(self) -> str | None: """Return the current effect.""" mode = self.light.mode.name.lower() if mode in self.effect_list: @@ -95,7 +94,7 @@ def effect(self): return None @property - def effect_list(self): + def effect_list(self) -> list[str]: """Return the list of supported effects.""" return [ effect diff --git a/homeassistant/components/smarttub/manifest.json b/homeassistant/components/smarttub/manifest.json index 49ea3ad5ced217..41eef391955032 100644 --- a/homeassistant/components/smarttub/manifest.json +++ b/homeassistant/components/smarttub/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@mdz"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/smarttub", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["smarttub"], "requirements": ["python-smarttub==0.0.47"] diff --git a/homeassistant/components/smarttub/quality_scale.yaml b/homeassistant/components/smarttub/quality_scale.yaml new file mode 100644 index 00000000000000..ad59af4144ffe4 --- /dev/null +++ b/homeassistant/components/smarttub/quality_scale.yaml @@ -0,0 +1,74 @@ +rules: + # Bronze + action-setup: todo + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: todo + docs-high-level-description: todo + docs-installation-instructions: todo + docs-removal-instructions: todo + entity-event-setup: + status: exempt + comment: Entities use coordinator polling, no explicit event subscriptions. + entity-unique-id: done + has-entity-name: todo + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration does not have configuration parameters. + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: done + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery: + status: exempt + comment: This is a cloud-only service with no local discovery mechanism. + discovery-update-info: + status: exempt + comment: This is a cloud-only service with no local discovery mechanism. + 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: Spa devices are fixed to the account and cannot be dynamically added or removed. + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: done + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: This integration does not raise any repairable issues. + stale-devices: + status: exempt + comment: Spa devices are fixed to the account and cannot be removed. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/smarttub/sensor.py b/homeassistant/components/smarttub/sensor.py index 059c3f8528dc0b..735229079a42e6 100644 --- a/homeassistant/components/smarttub/sensor.py +++ b/homeassistant/components/smarttub/sensor.py @@ -95,6 +95,17 @@ async def async_setup_entry( class SmartTubBuiltinSensor(SmartTubOnboardSensorBase, SensorEntity): """Generic class for SmartTub status sensors.""" + def __init__( + self, + coordinator: DataUpdateCoordinator[dict[str, Any]], + spa: smarttub.Spa, + sensor_name: str, + state_key: str, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator, spa, sensor_name, state_key) + self._attr_translation_key = state_key + @property def native_value(self) -> str | None: """Return the current state of the sensor.""" @@ -117,6 +128,7 @@ def __init__( super().__init__( coordinator, spa, "Primary Filtration Cycle", "primary_filtration" ) + self._attr_translation_key = "primary_filtration_cycle" @property def cycle(self) -> smarttub.SpaPrimaryFiltrationCycle: @@ -157,6 +169,7 @@ def __init__( super().__init__( coordinator, spa, "Secondary Filtration Cycle", "secondary_filtration" ) + self._attr_translation_key = "secondary_filtration_cycle" @property def cycle(self) -> smarttub.SpaSecondaryFiltrationCycle: diff --git a/homeassistant/components/smarttub/strings.json b/homeassistant/components/smarttub/strings.json index beff42e972012c..631be8fa0e8726 100644 --- a/homeassistant/components/smarttub/strings.json +++ b/homeassistant/components/smarttub/strings.json @@ -34,6 +34,69 @@ } } }, + "entity": { + "binary_sensor": { + "cover_sensor": { + "name": "Cover sensor" + }, + "error": { + "name": "Error" + }, + "online": { + "name": "Online" + }, + "reminder": { + "name": "{reminder_name} reminder" + } + }, + "climate": { + "thermostat": { + "name": "Thermostat" + } + }, + "light": { + "light_zone": { + "name": "Light {zone}" + } + }, + "sensor": { + "blowout_cycle": { + "name": "Blowout cycle" + }, + "cleanup_cycle": { + "name": "Cleanup cycle" + }, + "flow_switch": { + "name": "Flow switch" + }, + "ozone": { + "name": "Ozone" + }, + "primary_filtration_cycle": { + "name": "Primary filtration cycle" + }, + "secondary_filtration_cycle": { + "name": "Secondary filtration cycle" + }, + "state": { + "name": "State" + }, + "uv": { + "name": "UV" + } + }, + "switch": { + "circulation_pump": { + "name": "Circulation pump" + }, + "jet": { + "name": "Jet {pump_id}" + }, + "pump": { + "name": "Pump {pump_id}" + } + } + }, "services": { "reset_reminder": { "description": "Resets the maintenance reminder on a hot tub.", diff --git a/homeassistant/components/smarttub/switch.py b/homeassistant/components/smarttub/switch.py index d3fb0ecb1bdedc..4ce913dbfc4b12 100644 --- a/homeassistant/components/smarttub/switch.py +++ b/homeassistant/components/smarttub/switch.py @@ -13,7 +13,6 @@ from .const import API_TIMEOUT, ATTR_PUMPS from .controller import SmartTubConfigEntry from .entity import SmartTubEntity -from .helpers import get_spa_name PARALLEL_UPDATES = 0 @@ -47,22 +46,20 @@ def __init__( self.pump_id = pump.id self.pump_type = pump.type self._attr_unique_id = f"{super().unique_id}-{pump.id}" + if pump.type == SpaPump.PumpType.CIRCULATION: + self._attr_translation_key = "circulation_pump" + elif pump.type == SpaPump.PumpType.JET: + self._attr_translation_key = "jet" + self._attr_translation_placeholders = {"pump_id": str(pump.id)} + else: + self._attr_translation_key = "pump" + self._attr_translation_placeholders = {"pump_id": str(pump.id)} @property def pump(self) -> SpaPump: """Return the underlying SpaPump object for this entity.""" return self.coordinator.data[self.spa.id][ATTR_PUMPS][self.pump_id] - @property - def name(self) -> str: - """Return a name for this pump entity.""" - spa_name = get_spa_name(self.spa) - if self.pump_type == SpaPump.PumpType.CIRCULATION: - return f"{spa_name} Circulation Pump" - if self.pump_type == SpaPump.PumpType.JET: - return f"{spa_name} Jet {self.pump_id}" - return f"{spa_name} pump {self.pump_id}" - @property def is_on(self) -> bool: """Return True if the pump is on.""" diff --git a/homeassistant/components/smhi/manifest.json b/homeassistant/components/smhi/manifest.json index 391c1e02dd21bb..dbaf57364d6a14 100644 --- a/homeassistant/components/smhi/manifest.json +++ b/homeassistant/components/smhi/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@gjohansson-ST"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/smhi", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pysmhi"], "requirements": ["pysmhi==1.1.0"] diff --git a/homeassistant/components/smlight/button.py b/homeassistant/components/smlight/button.py index 67d9997a105744..e2c2e5454ba557 100644 --- a/homeassistant/components/smlight/button.py +++ b/homeassistant/components/smlight/button.py @@ -1,4 +1,4 @@ -"""Support for SLZB-06 buttons.""" +"""Support for SLZB buttons.""" from __future__ import annotations @@ -35,24 +35,25 @@ class SmButtonDescription(ButtonEntityDescription): press_fn: Callable[[CmdWrapper, int], Awaitable[None]] -BUTTONS: list[SmButtonDescription] = [ - SmButtonDescription( - key="core_restart", - translation_key="core_restart", - device_class=ButtonDeviceClass.RESTART, - press_fn=lambda cmd, idx: cmd.reboot(), - ), +CORE_BUTTON = SmButtonDescription( + key="core_restart", + translation_key="core_restart", + device_class=ButtonDeviceClass.RESTART, + press_fn=lambda cmd, idx: cmd.reboot(), +) + +RADIO_BUTTONS: list[SmButtonDescription] = [ SmButtonDescription( key="zigbee_restart", translation_key="zigbee_restart", device_class=ButtonDeviceClass.RESTART, - press_fn=lambda cmd, idx: cmd.zb_restart(), + press_fn=lambda cmd, idx: cmd.zb_restart(idx=idx), ), SmButtonDescription( key="zigbee_flash_mode", translation_key="zigbee_flash_mode", entity_registry_enabled_default=False, - press_fn=lambda cmd, idx: cmd.zb_bootloader(), + press_fn=lambda cmd, idx: cmd.zb_bootloader(idx=idx), ), ] @@ -73,8 +74,14 @@ async def async_setup_entry( coordinator = entry.runtime_data.data radios = coordinator.data.info.radios - async_add_entities(SmButton(coordinator, button) for button in BUTTONS) - entity_created = [False, False] + entities = [SmButton(coordinator, CORE_BUTTON)] + count = len(radios) if coordinator.data.info.u_device else 1 + + for idx in range(count): + entities.extend(SmButton(coordinator, button, idx) for button in RADIO_BUTTONS) + + async_add_entities(entities) + entity_created = [False] * len(radios) @callback def _check_router(startup: bool = False) -> None: @@ -103,7 +110,7 @@ def router_entity(router: SmButtonDescription, idx: int) -> None: class SmButton(SmEntity, ButtonEntity): - """Defines a SLZB-06 button.""" + """Defines a SLZB button.""" coordinator: SmDataUpdateCoordinator entity_description: SmButtonDescription @@ -115,7 +122,7 @@ def __init__( description: SmButtonDescription, idx: int = 0, ) -> None: - """Initialize SLZB-06 button entity.""" + """Initialize SLZB button entity.""" super().__init__(coordinator) self.entity_description = description diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index 33d6fcbafe338f..985799ab0e6c52 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.2.14"], + "requirements": ["pysmlight==0.3.1"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/homeassistant/components/snapcast/manifest.json b/homeassistant/components/snapcast/manifest.json index 80d3b6cd491391..21358156455fa6 100644 --- a/homeassistant/components/snapcast/manifest.json +++ b/homeassistant/components/snapcast/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@luar123"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/snapcast", + "integration_type": "hub", "iot_class": "local_push", "loggers": ["construct", "snapcast"], "requirements": ["snapcast==2.3.7"] diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index d4d5f98211e8b0..bccded10176a1b 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -131,7 +131,7 @@ def get_unique_id(cls, host, id) -> str: return f"{CLIENT_PREFIX}{host}_{id}" @property - def _current_group(self) -> Snapgroup: + def _current_group(self) -> Snapgroup | None: """Return the group the client is associated with.""" return self._device.group @@ -158,9 +158,17 @@ def name(self) -> str: def state(self) -> MediaPlayerState | None: """Return the state of the player.""" if self._device.connected: - if self.is_volume_muted or self._current_group.muted: + if ( + self.is_volume_muted + or self._current_group is None + or self._current_group.muted + ): return MediaPlayerState.IDLE - return STREAM_STATUS.get(self._current_group.stream_status) + try: + return STREAM_STATUS.get(self._current_group.stream_status) + except KeyError: + pass + return MediaPlayerState.OFF @property @@ -179,15 +187,31 @@ def latency(self) -> float | None: @property def source(self) -> str | None: """Return the current input source.""" + if self._current_group is None: + return None + return self._current_group.stream @property def source_list(self) -> list[str]: """List of available input sources.""" + if self._current_group is None: + return [] + return list(self._current_group.streams_by_name().keys()) async def async_select_source(self, source: str) -> None: """Set input source.""" + if self._current_group is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="select_source_no_group", + translation_placeholders={ + "entity_id": self.entity_id, + "source": source, + }, + ) + streams = self._current_group.streams_by_name() if source in streams: await self._current_group.set_stream(streams[source].identifier) @@ -230,6 +254,9 @@ async def async_set_latency(self, latency) -> None: @property def group_members(self) -> list[str] | None: """List of player entities which are currently grouped together for synchronous playback.""" + if self._current_group is None: + return None + entity_registry = er.async_get(self.hass) return [ entity_id @@ -245,6 +272,15 @@ def group_members(self) -> list[str] | None: async def async_join_players(self, group_members: list[str]) -> None: """Add `group_members` to this client's current group.""" + if self._current_group is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="join_players_no_group", + translation_placeholders={ + "entity_id": self.entity_id, + }, + ) + # Get the client entity for each group member excluding self entity_registry = er.async_get(self.hass) clients = [ @@ -254,31 +290,61 @@ async def async_join_players(self, group_members: list[str]) -> None: and entity.unique_id != self.unique_id ] + # Get unique ID prefix for this host + unique_id_prefix = self.get_unique_id(self.coordinator.host_id, "") for client in clients: - # Valid entity is a snapcast client + # Validate entity is a snapcast client if not client.unique_id.startswith(CLIENT_PREFIX): raise ServiceValidationError( f"Entity '{client.entity_id}' is not a Snapcast client device." ) + # Validate client belongs to the same server + if not client.unique_id.startswith(unique_id_prefix): + raise ServiceValidationError( + f"Entity '{client.entity_id}' does not belong to the same Snapcast server." + ) + # Extract client ID and join it to the current group - identifier = client.unique_id.split("_")[-1] - await self._current_group.add_client(identifier) + identifier = client.unique_id.removeprefix(unique_id_prefix) + try: + await self._current_group.add_client(identifier) + except KeyError as e: + raise ServiceValidationError( + f"Client with identifier '{identifier}' does not exist on the server." + ) from e self.async_write_ha_state() async def async_unjoin_player(self) -> None: - """Remove this client from it's current group.""" + """Remove this client from its current group.""" + if self._current_group is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unjoin_no_group", + translation_placeholders={ + "entity_id": self.entity_id, + }, + ) + await self._current_group.remove_client(self._device.identifier) self.async_write_ha_state() @property def metadata(self) -> Mapping[str, Any]: """Get metadata from the current stream.""" - if metadata := self.coordinator.server.stream( - self._current_group.stream - ).metadata: - return metadata + if self._current_group is None: + return {} + + try: + if metadata := self.coordinator.server.stream( + self._current_group.stream + ).metadata: + return metadata + except ( + KeyError + ): # the stream function raises KeyError if the stream does not exist + pass # Fallback to an empty dict return {} @@ -333,11 +399,18 @@ def media_duration(self) -> int | None: @property def media_position(self) -> int | None: """Position of current playing media in seconds.""" - # Position is part of properties object, not metadata object - if properties := self.coordinator.server.stream( - self._current_group.stream - ).properties: - if (value := properties.get("position")) is not None: - return int(value) - + if self._current_group is None: + return None + + try: + # Position is part of properties object, not metadata object + if properties := self.coordinator.server.stream( + self._current_group.stream + ).properties: + if (value := properties.get("position")) is not None: + return int(value) + except ( + KeyError + ): # the stream function raises KeyError if the stream does not exist + pass return None diff --git a/homeassistant/components/snapcast/strings.json b/homeassistant/components/snapcast/strings.json index 361cb4eeb4f631..7414fe1b007311 100644 --- a/homeassistant/components/snapcast/strings.json +++ b/homeassistant/components/snapcast/strings.json @@ -21,6 +21,17 @@ } } }, + "exceptions": { + "join_players_no_group": { + "message": "Client {entity_id} has no group. Unable to join players." + }, + "select_source_no_group": { + "message": "Client {entity_id} has no group. Unable to select source {source}." + }, + "unjoin_no_group": { + "message": "Client {entity_id} has no group. Unable to unjoin player." + } + }, "services": { "restore": { "description": "Restores a previously taken snapshot of a media player.", diff --git a/homeassistant/components/snoo/manifest.json b/homeassistant/components/snoo/manifest.json index 5a162a9e9d3d28..916535b1563287 100644 --- a/homeassistant/components/snoo/manifest.json +++ b/homeassistant/components/snoo/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@Lash-L"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/snoo", + "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["snoo"], "quality_scale": "bronze", diff --git a/homeassistant/components/snooz/manifest.json b/homeassistant/components/snooz/manifest.json index 5b43aa7e92d6ab..a0a10f23a6f4d9 100644 --- a/homeassistant/components/snooz/manifest.json +++ b/homeassistant/components/snooz/manifest.json @@ -13,6 +13,7 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/snooz", + "integration_type": "device", "iot_class": "local_push", "requirements": ["pysnooz==0.8.6"] } diff --git a/homeassistant/components/solaredge_local/sensor.py b/homeassistant/components/solaredge_local/sensor.py index d8621a139c0118..f362a5e029f20d 100644 --- a/homeassistant/components/solaredge_local/sensor.py +++ b/homeassistant/components/solaredge_local/sensor.py @@ -7,6 +7,7 @@ from datetime import timedelta import logging import statistics +from typing import Any from requests.exceptions import ConnectTimeout, HTTPError from solaredge_local import SolarEdge @@ -289,7 +290,7 @@ def __init__( self._attr_name = f"{platform_name} ({description.name})" @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes.""" if extra_attr := self.entity_description.extra_attribute: try: diff --git a/homeassistant/components/solarlog/manifest.json b/homeassistant/components/solarlog/manifest.json index 8d7b8526668002..b9b47dbbaa2cd6 100644 --- a/homeassistant/components/solarlog/manifest.json +++ b/homeassistant/components/solarlog/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@Ernst79", "@dontinelli"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/solarlog", + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["solarlog_cli"], "quality_scale": "platinum", diff --git a/homeassistant/components/solax/manifest.json b/homeassistant/components/solax/manifest.json index 5509901ae02189..d72924109588cd 100644 --- a/homeassistant/components/solax/manifest.json +++ b/homeassistant/components/solax/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@squishykid", "@Darsstar"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/solax", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["solax"], "requirements": ["solax==3.2.3"] diff --git a/homeassistant/components/soma/manifest.json b/homeassistant/components/soma/manifest.json index ed0c5ff6240566..1e080ade626bdf 100644 --- a/homeassistant/components/soma/manifest.json +++ b/homeassistant/components/soma/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@ratsept"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/soma", + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["api"], "requirements": ["pysoma==0.0.12"] diff --git a/homeassistant/components/somfy_mylink/manifest.json b/homeassistant/components/somfy_mylink/manifest.json index 86fab41c9a13a4..fa815d808b4a1d 100644 --- a/homeassistant/components/somfy_mylink/manifest.json +++ b/homeassistant/components/somfy_mylink/manifest.json @@ -10,6 +10,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/somfy_mylink", + "integration_type": "hub", "iot_class": "assumed_state", "loggers": ["somfy_mylink_synergy"], "requirements": ["somfy-mylink-synergy==1.0.6"] diff --git a/homeassistant/components/sonarr/__init__.py b/homeassistant/components/sonarr/__init__.py index 1c786356486f83..6d561dd9f22960 100644 --- a/homeassistant/components/sonarr/__init__.py +++ b/homeassistant/components/sonarr/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any +from dataclasses import fields from aiopyarr.models.host_configuration import PyArrHostConfiguration from aiopyarr.sonarr_client import SonarrClient @@ -18,7 +18,9 @@ Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType from .const import ( CONF_BASE_PATH, @@ -35,15 +37,25 @@ DiskSpaceDataUpdateCoordinator, QueueDataUpdateCoordinator, SeriesDataUpdateCoordinator, - SonarrDataUpdateCoordinator, + SonarrConfigEntry, + SonarrData, StatusDataUpdateCoordinator, WantedDataUpdateCoordinator, ) +from .services import async_setup_services PLATFORMS = [Platform.SENSOR] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Sonarr integration.""" + async_setup_services(hass) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: SonarrConfigEntry) -> bool: """Set up Sonarr from a config entry.""" if not entry.options: options = { @@ -65,29 +77,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: host_configuration=host_configuration, session=async_get_clientsession(hass), ) - coordinators: dict[str, SonarrDataUpdateCoordinator[Any]] = { - "upcoming": CalendarDataUpdateCoordinator( - hass, entry, host_configuration, sonarr - ), - "commands": CommandsDataUpdateCoordinator( + data = SonarrData( + upcoming=CalendarDataUpdateCoordinator(hass, entry, host_configuration, sonarr), + commands=CommandsDataUpdateCoordinator(hass, entry, host_configuration, sonarr), + diskspace=DiskSpaceDataUpdateCoordinator( hass, entry, host_configuration, sonarr ), - "diskspace": DiskSpaceDataUpdateCoordinator( - hass, entry, host_configuration, sonarr - ), - "queue": QueueDataUpdateCoordinator(hass, entry, host_configuration, sonarr), - "series": SeriesDataUpdateCoordinator(hass, entry, host_configuration, sonarr), - "status": StatusDataUpdateCoordinator(hass, entry, host_configuration, sonarr), - "wanted": WantedDataUpdateCoordinator(hass, entry, host_configuration, sonarr), - } + queue=QueueDataUpdateCoordinator(hass, entry, host_configuration, sonarr), + series=SeriesDataUpdateCoordinator(hass, entry, host_configuration, sonarr), + status=StatusDataUpdateCoordinator(hass, entry, host_configuration, sonarr), + wanted=WantedDataUpdateCoordinator(hass, entry, host_configuration, sonarr), + ) # Temporary, until we add diagnostic entities _version = None - for coordinator in coordinators.values(): + for field in fields(data): + coordinator = getattr(data, field.name) await coordinator.async_config_entry_first_refresh() if isinstance(coordinator, StatusDataUpdateCoordinator): _version = coordinator.data.version coordinator.system_version = _version - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinators + entry.runtime_data = data await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -117,11 +126,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: SonarrConfigEntry) -> 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/sonarr/const.py b/homeassistant/components/sonarr/const.py index 7e703f0295769f..ef8501170465d1 100644 --- a/homeassistant/components/sonarr/const.py +++ b/homeassistant/components/sonarr/const.py @@ -1,8 +1,9 @@ """Constants for Sonarr.""" import logging +from typing import Final -DOMAIN = "sonarr" +DOMAIN: Final = "sonarr" # Config Keys CONF_BASE_PATH = "base_path" @@ -17,5 +18,20 @@ DEFAULT_UPCOMING_DAYS = 1 DEFAULT_VERIFY_SSL = False DEFAULT_WANTED_MAX_ITEMS = 50 +DEFAULT_MAX_RECORDS: Final = 20 LOGGER = logging.getLogger(__package__) + +# Service names +SERVICE_GET_SERIES: Final = "get_series" +SERVICE_GET_EPISODES: Final = "get_episodes" +SERVICE_GET_QUEUE: Final = "get_queue" +SERVICE_GET_DISKSPACE: Final = "get_diskspace" +SERVICE_GET_UPCOMING: Final = "get_upcoming" +SERVICE_GET_WANTED: Final = "get_wanted" + +# Service attributes +ATTR_SHOWS: Final = "shows" +ATTR_DISKS: Final = "disks" +ATTR_EPISODES: Final = "episodes" +ATTR_ENTRY_ID: Final = "entry_id" diff --git a/homeassistant/components/sonarr/coordinator.py b/homeassistant/components/sonarr/coordinator.py index a73ef8385907c4..3e50527f285854 100644 --- a/homeassistant/components/sonarr/coordinator.py +++ b/homeassistant/components/sonarr/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta from typing import TypeVar, cast @@ -40,15 +41,31 @@ ) +@dataclass +class SonarrData: + """Sonarr data type.""" + + upcoming: CalendarDataUpdateCoordinator + commands: CommandsDataUpdateCoordinator + diskspace: DiskSpaceDataUpdateCoordinator + queue: QueueDataUpdateCoordinator + series: SeriesDataUpdateCoordinator + status: StatusDataUpdateCoordinator + wanted: WantedDataUpdateCoordinator + + +type SonarrConfigEntry = ConfigEntry[SonarrData] + + class SonarrDataUpdateCoordinator(DataUpdateCoordinator[SonarrDataT]): """Data update coordinator for the Sonarr integration.""" - config_entry: ConfigEntry + config_entry: SonarrConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SonarrConfigEntry, host_configuration: PyArrHostConfiguration, api_client: SonarrClient, ) -> None: diff --git a/homeassistant/components/sonarr/helpers.py b/homeassistant/components/sonarr/helpers.py new file mode 100644 index 00000000000000..522009785b1783 --- /dev/null +++ b/homeassistant/components/sonarr/helpers.py @@ -0,0 +1,387 @@ +"""Helper functions for Sonarr.""" + +from typing import Any + +from aiopyarr import ( + Diskspace, + SonarrCalendar, + SonarrEpisode, + SonarrQueue, + SonarrSeries, + SonarrWantedMissing, +) + + +def format_queue_item(item: Any, base_url: str | None = None) -> dict[str, Any]: + """Format a single queue item.""" + # Calculate progress + remaining = 1 if item.size == 0 else item.sizeleft / item.size + remaining_pct = 100 * (1 - remaining) + + result: dict[str, Any] = { + "id": item.id, + "series_id": getattr(item, "seriesId", None), + "episode_id": getattr(item, "episodeId", None), + "title": item.series.title, + "download_title": item.title, + "season_number": getattr(item, "seasonNumber", None), + "progress": f"{remaining_pct:.2f}%", + "size": item.size, + "size_left": item.sizeleft, + "status": item.status, + "tracked_download_status": getattr(item, "trackedDownloadStatus", None), + "tracked_download_state": getattr(item, "trackedDownloadState", None), + "download_client": getattr(item, "downloadClient", None), + "download_id": getattr(item, "downloadId", None), + "indexer": getattr(item, "indexer", None), + "protocol": str(getattr(item, "protocol", None)), + "episode_has_file": getattr(item, "episodeHasFile", None), + "estimated_completion_time": str( + getattr(item, "estimatedCompletionTime", None) + ), + "time_left": str(getattr(item, "timeleft", None)), + } + + # Add episode information from the episode object if available + if episode := getattr(item, "episode", None): + result["episode_number"] = getattr(episode, "episodeNumber", None) + result["episode_title"] = getattr(episode, "title", None) + # Add formatted identifier like the sensor uses (if we have both season and episode) + if result["season_number"] is not None and result["episode_number"] is not None: + result["episode_identifier"] = ( + f"S{result['season_number']:02d}E{result['episode_number']:02d}" + ) + + # Add quality information if available + if quality := getattr(item, "quality", None): + result["quality"] = quality.quality.name + + # Add language information if available + if languages := getattr(item, "languages", None): + result["languages"] = [lang["name"] for lang in languages] + + # Add custom format score if available + if custom_format_score := getattr(item, "customFormatScore", None): + result["custom_format_score"] = custom_format_score + + # Add series images if available + if images := getattr(item.series, "images", None): + result["images"] = {} + for image in images: + cover_type = image.coverType + # Prefer remoteUrl (public TVDB URL) over local path + if remote_url := getattr(image, "remoteUrl", None): + result["images"][cover_type] = remote_url + elif base_url and (url := getattr(image, "url", None)): + result["images"][cover_type] = f"{base_url.rstrip('/')}{url}" + + return result + + +def format_queue( + queue: SonarrQueue, base_url: str | None = None +) -> dict[str, dict[str, Any]]: + """Format queue for service response.""" + # Group queue items by download ID to handle season packs + downloads: dict[str, list[Any]] = {} + for item in queue.records: + download_id = getattr(item, "downloadId", None) + if download_id: + if download_id not in downloads: + downloads[download_id] = [] + downloads[download_id].append(item) + + shows = {} + for items in downloads.values(): + if len(items) == 1: + # Single episode download + item = items[0] + shows[item.title] = format_queue_item(item, base_url) + else: + # Multiple episodes (season pack) - use first item for main data + item = items[0] + formatted = format_queue_item(item, base_url) + + # Get all episode numbers for this download + episode_numbers = sorted( + getattr(i.episode, "episodeNumber", 0) + for i in items + if hasattr(i, "episode") + ) + + # Format as season pack + if episode_numbers: + min_ep = min(episode_numbers) + max_ep = max(episode_numbers) + formatted["is_season_pack"] = True + formatted["episode_count"] = len(episode_numbers) + formatted["episode_range"] = f"E{min_ep:02d}-E{max_ep:02d}" + # Update identifier to show it's a season pack + if formatted.get("season_number") is not None: + formatted["episode_identifier"] = ( + f"S{formatted['season_number']:02d} " + f"({len(episode_numbers)} episodes)" + ) + + shows[item.title] = formatted + + return shows + + +def format_series( + series_list: list[SonarrSeries], base_url: str | None = None +) -> dict[str, dict[str, Any]]: + """Format series list for service response.""" + formatted_shows = {} + + for series in series_list: + series_title = series.title + formatted_shows[series_title] = { + "id": series.id, + "year": series.year, + "tvdb_id": getattr(series, "tvdbId", None), + "imdb_id": getattr(series, "imdbId", None), + "status": series.status, + "monitored": series.monitored, + } + + # Add episode statistics if available (like the sensor shows) + if statistics := getattr(series, "statistics", None): + episode_file_count = getattr(statistics, "episodeFileCount", None) + episode_count = getattr(statistics, "episodeCount", None) + formatted_shows[series_title]["episode_file_count"] = episode_file_count + formatted_shows[series_title]["episode_count"] = episode_count + # Only format episodes_info if we have valid data + if episode_file_count is not None and episode_count is not None: + formatted_shows[series_title]["episodes_info"] = ( + f"{episode_file_count}/{episode_count} Episodes" + ) + else: + formatted_shows[series_title]["episodes_info"] = None + + # Add series images if available + if images := getattr(series, "images", None): + images_dict: dict[str, str] = {} + for image in images: + cover_type = image.coverType + # Prefer remoteUrl (public TVDB URL) over local path + if remote_url := getattr(image, "remoteUrl", None): + images_dict[cover_type] = remote_url + elif base_url and (url := getattr(image, "url", None)): + images_dict[cover_type] = f"{base_url.rstrip('/')}{url}" + formatted_shows[series_title]["images"] = images_dict + + return formatted_shows + + +# Space unit conversion factors (divisors from bytes) +SPACE_UNITS: dict[str, int] = { + "bytes": 1, + "kb": 1000, + "kib": 1024, + "mb": 1000**2, + "mib": 1024**2, + "gb": 1000**3, + "gib": 1024**3, + "tb": 1000**4, + "tib": 1024**4, + "pb": 1000**5, + "pib": 1024**5, +} + + +def format_diskspace( + disks: list[Diskspace], space_unit: str = "bytes" +) -> dict[str, dict[str, Any]]: + """Format diskspace for service response. + + Args: + disks: List of disk space objects from Sonarr. + space_unit: Unit for space values (bytes, kb, kib, mb, mib, gb, gib, tb, tib, pb, pib). + + Returns: + Dictionary of disk information keyed by path. + """ + result = {} + divisor = SPACE_UNITS.get(space_unit, 1) + + for disk in disks: + path = disk.path + free_space = disk.freeSpace / divisor + total_space = disk.totalSpace / divisor + + result[path] = { + "path": path, + "label": getattr(disk, "label", None) or "", + "free_space": free_space, + "total_space": total_space, + "unit": space_unit, + } + + return result + + +def _format_series_images(series: Any, base_url: str | None = None) -> dict[str, str]: + """Format series images.""" + images_dict: dict[str, str] = {} + if images := getattr(series, "images", None): + for image in images: + cover_type = image.coverType + # Prefer remoteUrl (public TVDB URL) over local path + if remote_url := getattr(image, "remoteUrl", None): + images_dict[cover_type] = remote_url + elif base_url and (url := getattr(image, "url", None)): + images_dict[cover_type] = f"{base_url.rstrip('/')}{url}" + return images_dict + + +def format_upcoming_item( + episode: SonarrCalendar, base_url: str | None = None +) -> dict[str, Any]: + """Format a single upcoming episode item.""" + result: dict[str, Any] = { + "id": episode.id, + "series_id": episode.seriesId, + "season_number": episode.seasonNumber, + "episode_number": episode.episodeNumber, + "episode_identifier": f"S{episode.seasonNumber:02d}E{episode.episodeNumber:02d}", + "title": episode.title, + "air_date": str(getattr(episode, "airDate", None)), + "air_date_utc": str(getattr(episode, "airDateUtc", None)), + "overview": getattr(episode, "overview", None), + "has_file": getattr(episode, "hasFile", False), + "monitored": getattr(episode, "monitored", True), + "runtime": getattr(episode, "runtime", None), + "finale_type": getattr(episode, "finaleType", None), + } + + # Add series information + if series := getattr(episode, "series", None): + result["series_title"] = series.title + result["series_year"] = getattr(series, "year", None) + result["series_tvdb_id"] = getattr(series, "tvdbId", None) + result["series_imdb_id"] = getattr(series, "imdbId", None) + result["series_status"] = getattr(series, "status", None) + result["network"] = getattr(series, "network", None) + result["images"] = _format_series_images(series, base_url) + + return result + + +def format_upcoming( + calendar: list[SonarrCalendar], base_url: str | None = None +) -> dict[str, dict[str, Any]]: + """Format upcoming calendar for service response.""" + episodes = {} + + 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" + identifier = f"S{episode.seasonNumber:02d}E{episode.episodeNumber:02d}" + key = f"{series_title} {identifier}" + episodes[key] = format_upcoming_item(episode, base_url) + + return episodes + + +def format_wanted_item(item: Any, base_url: str | None = None) -> dict[str, Any]: + """Format a single wanted episode item.""" + result: dict[str, Any] = { + "id": item.id, + "series_id": item.seriesId, + "season_number": item.seasonNumber, + "episode_number": item.episodeNumber, + "episode_identifier": f"S{item.seasonNumber:02d}E{item.episodeNumber:02d}", + "title": item.title, + "air_date": str(getattr(item, "airDate", None)), + "air_date_utc": str(getattr(item, "airDateUtc", None)), + "overview": getattr(item, "overview", None), + "has_file": getattr(item, "hasFile", False), + "monitored": getattr(item, "monitored", True), + "runtime": getattr(item, "runtime", None), + "tvdb_id": getattr(item, "tvdbId", None), + } + + # Add series information + if series := getattr(item, "series", None): + result["series_title"] = series.title + result["series_year"] = getattr(series, "year", None) + result["series_tvdb_id"] = getattr(series, "tvdbId", None) + result["series_imdb_id"] = getattr(series, "imdbId", None) + result["series_status"] = getattr(series, "status", None) + result["network"] = getattr(series, "network", None) + result["images"] = _format_series_images(series, base_url) + + return result + + +def format_wanted( + wanted: SonarrWantedMissing, base_url: str | None = None +) -> dict[str, dict[str, Any]]: + """Format wanted missing episodes for service response.""" + episodes = {} + + 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" + ) + identifier = f"S{item.seasonNumber:02d}E{item.episodeNumber:02d}" + key = f"{series_title} {identifier}" + episodes[key] = format_wanted_item(item, base_url) + + return episodes + + +def format_episode(episode: SonarrEpisode) -> dict[str, Any]: + """Format a single episode from a series.""" + result: dict[str, Any] = { + "id": episode.id, + "series_id": episode.seriesId, + "tvdb_id": getattr(episode, "tvdbId", None), + "season_number": episode.seasonNumber, + "episode_number": episode.episodeNumber, + "episode_identifier": f"S{episode.seasonNumber:02d}E{episode.episodeNumber:02d}", + "title": episode.title, + "air_date": str(getattr(episode, "airDate", None)), + "air_date_utc": str(getattr(episode, "airDateUtc", None)), + "has_file": getattr(episode, "hasFile", False), + "monitored": getattr(episode, "monitored", False), + "runtime": getattr(episode, "runtime", None), + "episode_file_id": getattr(episode, "episodeFileId", None), + } + + # Add overview if available (not always present) + if overview := getattr(episode, "overview", None): + result["overview"] = overview + + # Add finale type if applicable + if finale_type := getattr(episode, "finaleType", None): + result["finale_type"] = finale_type + + return result + + +def format_episodes( + episodes: list[SonarrEpisode], season_number: int | None = None +) -> dict[str, dict[str, Any]]: + """Format episodes list for service response. + + Args: + episodes: List of episodes to format. + season_number: Optional season number to filter by. + + Returns: + Dictionary of episodes keyed by episode identifier (e.g., "S01E01"). + """ + result = {} + + for episode in episodes: + # Filter by season if specified + if season_number is not None and episode.seasonNumber != season_number: + continue + + identifier = f"S{episode.seasonNumber:02d}E{episode.episodeNumber:02d}" + result[identifier] = format_episode(episode) + + return result diff --git a/homeassistant/components/sonarr/icons.json b/homeassistant/components/sonarr/icons.json index 7980db52b297c7..49e4bf3032ac7d 100644 --- a/homeassistant/components/sonarr/icons.json +++ b/homeassistant/components/sonarr/icons.json @@ -20,5 +20,25 @@ "default": "mdi:television" } } + }, + "services": { + "get_diskspace": { + "service": "mdi:harddisk" + }, + "get_episodes": { + "service": "mdi:filmstrip" + }, + "get_queue": { + "service": "mdi:download" + }, + "get_series": { + "service": "mdi:television" + }, + "get_upcoming": { + "service": "mdi:calendar-clock" + }, + "get_wanted": { + "service": "mdi:magnify" + } } } diff --git a/homeassistant/components/sonarr/manifest.json b/homeassistant/components/sonarr/manifest.json index c81dc9c39729d1..8b8fd91e5c3eb0 100644 --- a/homeassistant/components/sonarr/manifest.json +++ b/homeassistant/components/sonarr/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@ctalkington"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sonarr", + "integration_type": "service", "iot_class": "local_polling", "loggers": ["aiopyarr"], "requirements": ["aiopyarr==23.4.0"] diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py index 983ac76d93e74a..3aeb4348e6d866 100644 --- a/homeassistant/components/sonarr/sensor.py +++ b/homeassistant/components/sonarr/sensor.py @@ -20,15 +20,13 @@ SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util -from .const import DOMAIN -from .coordinator import SonarrDataT, SonarrDataUpdateCoordinator +from .coordinator import SonarrConfigEntry, SonarrDataT, SonarrDataUpdateCoordinator from .entity import SonarrEntity @@ -40,7 +38,7 @@ class SonarrSensorEntityDescriptionMixIn(Generic[SonarrDataT]): value_fn: Callable[[SonarrDataT], StateType] -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class SonarrSensorEntityDescription( SensorEntityDescription, SonarrSensorEntityDescriptionMixIn[SonarrDataT] ): @@ -143,15 +141,12 @@ def get_wanted_attr(wanted: SonarrWantedMissing) -> dict[str, str]: async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SonarrConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sonarr sensors based on a config entry.""" - coordinators: dict[str, SonarrDataUpdateCoordinator[Any]] = hass.data[DOMAIN][ - entry.entry_id - ] async_add_entities( - SonarrSensor(coordinators[coordinator_type], description) + SonarrSensor(getattr(entry.runtime_data, coordinator_type), description) for coordinator_type, description in SENSOR_TYPES.items() ) @@ -162,6 +157,7 @@ class SonarrSensor(SonarrEntity[SonarrDataT], SensorEntity): coordinator: SonarrDataUpdateCoordinator[SonarrDataT] entity_description: SonarrSensorEntityDescription[SonarrDataT] + # Note: Sensor extra_state_attributes are deprecated and will be removed in 2026.9 @property def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes of the entity.""" diff --git a/homeassistant/components/sonarr/services.py b/homeassistant/components/sonarr/services.py new file mode 100644 index 00000000000000..acd0bd11e479ec --- /dev/null +++ b/homeassistant/components/sonarr/services.py @@ -0,0 +1,284 @@ +"""Define services for the Sonarr integration.""" + +from collections.abc import Awaitable, Callable +from datetime import timedelta +from typing import Any, cast + +from aiopyarr import exceptions +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import selector +from homeassistant.util import dt as dt_util + +from .const import ( + ATTR_DISKS, + ATTR_ENTRY_ID, + ATTR_EPISODES, + ATTR_SHOWS, + DEFAULT_UPCOMING_DAYS, + DOMAIN, + SERVICE_GET_DISKSPACE, + SERVICE_GET_EPISODES, + SERVICE_GET_QUEUE, + SERVICE_GET_SERIES, + SERVICE_GET_UPCOMING, + SERVICE_GET_WANTED, +) +from .coordinator import SonarrConfigEntry +from .helpers import ( + format_diskspace, + format_episodes, + format_queue, + format_series, + format_upcoming, + format_wanted, +) + +# Service parameter constants +CONF_DAYS = "days" +CONF_MAX_ITEMS = "max_items" +CONF_SERIES_ID = "series_id" +CONF_SEASON_NUMBER = "season_number" +CONF_SPACE_UNIT = "space_unit" + +# Valid space units +SPACE_UNITS = ["bytes", "KB", "KiB", "MB", "MiB", "GB", "GiB", "TB", "TiB", "PB", "PiB"] +DEFAULT_SPACE_UNIT = "bytes" + +# Default values - 0 means no limit +DEFAULT_MAX_ITEMS = 0 + +SERVICE_BASE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTRY_ID): selector.ConfigEntrySelector( + {"integration": DOMAIN} + ), + } +) + +SERVICE_GET_SERIES_SCHEMA = SERVICE_BASE_SCHEMA + +SERVICE_GET_EPISODES_SCHEMA = SERVICE_BASE_SCHEMA.extend( + { + vol.Required(CONF_SERIES_ID): vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Optional(CONF_SEASON_NUMBER): vol.All(vol.Coerce(int), vol.Range(min=0)), + } +) + +SERVICE_GET_QUEUE_SCHEMA = SERVICE_BASE_SCHEMA.extend( + { + vol.Optional(CONF_MAX_ITEMS, default=DEFAULT_MAX_ITEMS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=500) + ), + } +) + +SERVICE_GET_DISKSPACE_SCHEMA = SERVICE_BASE_SCHEMA.extend( + { + vol.Optional(CONF_SPACE_UNIT, default=DEFAULT_SPACE_UNIT): vol.In(SPACE_UNITS), + } +) + +SERVICE_GET_UPCOMING_SCHEMA = SERVICE_BASE_SCHEMA.extend( + { + vol.Optional(CONF_DAYS, default=DEFAULT_UPCOMING_DAYS): vol.All( + vol.Coerce(int), vol.Range(min=1, max=30) + ), + } +) + +SERVICE_GET_WANTED_SCHEMA = SERVICE_BASE_SCHEMA.extend( + { + vol.Optional(CONF_MAX_ITEMS, default=DEFAULT_MAX_ITEMS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=500) + ), + } +) + + +def _get_config_entry_from_service_data(call: ServiceCall) -> SonarrConfigEntry: + """Return config entry for entry id.""" + config_entry_id: str = call.data[ATTR_ENTRY_ID] + if not (entry := call.hass.config_entries.async_get_entry(config_entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": config_entry_id}, + ) + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": entry.title}, + ) + return cast(SonarrConfigEntry, entry) + + +async def _handle_api_errors[_T](func: Callable[[], Awaitable[_T]]) -> _T: + """Handle API errors and raise HomeAssistantError with user-friendly messages.""" + try: + return await func() + except exceptions.ArrAuthenticationException as ex: + raise HomeAssistantError("Authentication failed for Sonarr") from ex + except exceptions.ArrConnectionException as ex: + raise HomeAssistantError("Failed to connect to Sonarr") from ex + except exceptions.ArrException as ex: + raise HomeAssistantError(f"Sonarr API error: {ex}") from ex + + +async def _async_get_series(service: ServiceCall) -> dict[str, Any]: + """Get all Sonarr series.""" + entry = _get_config_entry_from_service_data(service) + + api_client = entry.runtime_data.status.api_client + series_list = await _handle_api_errors(api_client.async_get_series) + + base_url = entry.data[CONF_URL] + shows = format_series(cast(list, series_list), base_url) + + return {ATTR_SHOWS: shows} + + +async def _async_get_episodes(service: ServiceCall) -> dict[str, Any]: + """Get episodes for a specific series.""" + entry = _get_config_entry_from_service_data(service) + series_id: int = service.data[CONF_SERIES_ID] + season_number: int | None = service.data.get(CONF_SEASON_NUMBER) + + api_client = entry.runtime_data.status.api_client + episodes = await _handle_api_errors( + lambda: api_client.async_get_episodes(series_id, series=True) + ) + + formatted_episodes = format_episodes(cast(list, episodes), season_number) + + return {ATTR_EPISODES: formatted_episodes} + + +async def _async_get_queue(service: ServiceCall) -> dict[str, Any]: + """Get Sonarr queue.""" + entry = _get_config_entry_from_service_data(service) + max_items: int = service.data[CONF_MAX_ITEMS] + + api_client = entry.runtime_data.status.api_client + # 0 means no limit - use a large page size to get all items + page_size = max_items if max_items > 0 else 10000 + queue = await _handle_api_errors( + lambda: api_client.async_get_queue( + page_size=page_size, include_series=True, include_episode=True + ) + ) + + base_url = entry.data[CONF_URL] + shows = format_queue(queue, base_url) + + return {ATTR_SHOWS: shows} + + +async def _async_get_diskspace(service: ServiceCall) -> dict[str, Any]: + """Get Sonarr diskspace information.""" + entry = _get_config_entry_from_service_data(service) + space_unit: str = service.data[CONF_SPACE_UNIT] + + api_client = entry.runtime_data.status.api_client + disks = await _handle_api_errors(api_client.async_get_diskspace) + + return {ATTR_DISKS: format_diskspace(disks, space_unit)} + + +async def _async_get_upcoming(service: ServiceCall) -> dict[str, Any]: + """Get Sonarr upcoming episodes.""" + entry = _get_config_entry_from_service_data(service) + days: int = service.data[CONF_DAYS] + + api_client = entry.runtime_data.status.api_client + + local = dt_util.start_of_local_day().replace(microsecond=0) + start = dt_util.as_utc(local) + end = start + timedelta(days=days) + + calendar = await _handle_api_errors( + lambda: api_client.async_get_calendar( + start_date=start, end_date=end, include_series=True + ) + ) + + base_url = entry.data[CONF_URL] + episodes = format_upcoming(cast(list, calendar), base_url) + + return {ATTR_EPISODES: episodes} + + +async def _async_get_wanted(service: ServiceCall) -> dict[str, Any]: + """Get Sonarr wanted/missing episodes.""" + entry = _get_config_entry_from_service_data(service) + max_items: int = service.data[CONF_MAX_ITEMS] + + api_client = entry.runtime_data.status.api_client + # 0 means no limit - use a large page size to get all items + page_size = max_items if max_items > 0 else 10000 + wanted = await _handle_api_errors( + lambda: api_client.async_get_wanted(page_size=page_size, include_series=True) + ) + + base_url = entry.data[CONF_URL] + episodes = format_wanted(wanted, base_url) + + return {ATTR_EPISODES: episodes} + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register services for the Sonarr integration.""" + + hass.services.async_register( + DOMAIN, + SERVICE_GET_SERIES, + _async_get_series, + schema=SERVICE_GET_SERIES_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_EPISODES, + _async_get_episodes, + schema=SERVICE_GET_EPISODES_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_QUEUE, + _async_get_queue, + schema=SERVICE_GET_QUEUE_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_DISKSPACE, + _async_get_diskspace, + schema=SERVICE_GET_DISKSPACE_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_UPCOMING, + _async_get_upcoming, + schema=SERVICE_GET_UPCOMING_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_WANTED, + _async_get_wanted, + schema=SERVICE_GET_WANTED_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/sonarr/services.yaml b/homeassistant/components/sonarr/services.yaml new file mode 100644 index 00000000000000..ee3f4a61c34f2f --- /dev/null +++ b/homeassistant/components/sonarr/services.yaml @@ -0,0 +1,100 @@ +get_series: + fields: + entry_id: + required: true + selector: + config_entry: + integration: sonarr + +get_queue: + fields: + entry_id: + required: true + selector: + config_entry: + integration: sonarr + max_items: + required: false + default: 0 + selector: + number: + min: 0 + max: 500 + mode: box + +get_diskspace: + fields: + entry_id: + required: true + selector: + config_entry: + integration: sonarr + space_unit: + required: false + default: bytes + selector: + select: + options: + - bytes + - kb + - kib + - mb + - mib + - gb + - gib + - tb + - tib + - pb + - pib + +get_upcoming: + fields: + entry_id: + required: true + selector: + config_entry: + integration: sonarr + days: + required: false + default: 1 + selector: + number: + min: 1 + max: 30 + mode: box + +get_wanted: + fields: + entry_id: + required: true + selector: + config_entry: + integration: sonarr + max_items: + required: false + default: 0 + selector: + number: + min: 0 + max: 500 + mode: box + +get_episodes: + fields: + entry_id: + required: true + selector: + config_entry: + integration: sonarr + series_id: + required: true + selector: + number: + min: 1 + mode: box + season_number: + required: false + selector: + number: + min: 0 + mode: box diff --git a/homeassistant/components/sonarr/strings.json b/homeassistant/components/sonarr/strings.json index 6424825e1ad0e7..0316e034d708ea 100644 --- a/homeassistant/components/sonarr/strings.json +++ b/homeassistant/components/sonarr/strings.json @@ -51,6 +51,14 @@ } } }, + "exceptions": { + "integration_not_found": { + "message": "Config entry for integration \"{target}\" not found." + }, + "not_loaded": { + "message": "Config entry \"{target}\" is not loaded." + } + }, "options": { "step": { "init": { @@ -60,5 +68,91 @@ } } } + }, + "services": { + "get_diskspace": { + "description": "Gets disk space information for all configured paths.", + "fields": { + "entry_id": { + "description": "ID of the config entry to use.", + "name": "Sonarr entry" + }, + "space_unit": { + "description": "Unit for space values. Use binary units (KiB, MiB, GiB, TiB, PiB) for 1024-based values or decimal units (KB, MB, GB, TB, PB) for 1000-based values. The default is bytes.", + "name": "Space unit" + } + }, + "name": "Get disk space" + }, + "get_episodes": { + "description": "Gets episodes for a specific series.", + "fields": { + "entry_id": { + "description": "[%key:component::sonarr::services::get_diskspace::fields::entry_id::description%]", + "name": "[%key:component::sonarr::services::get_diskspace::fields::entry_id::name%]" + }, + "season_number": { + "description": "Optional season number to filter episodes by.", + "name": "Season number" + }, + "series_id": { + "description": "The ID of the series to get episodes for.", + "name": "Series ID" + } + }, + "name": "Get episodes" + }, + "get_queue": { + "description": "Gets all episodes currently in the download queue with their progress and details.", + "fields": { + "entry_id": { + "description": "[%key:component::sonarr::services::get_diskspace::fields::entry_id::description%]", + "name": "[%key:component::sonarr::services::get_diskspace::fields::entry_id::name%]" + }, + "max_items": { + "description": "Maximum number of items to return (0 = no limit).", + "name": "Max items" + } + }, + "name": "Get queue" + }, + "get_series": { + "description": "Gets all series in Sonarr with their details and statistics.", + "fields": { + "entry_id": { + "description": "[%key:component::sonarr::services::get_diskspace::fields::entry_id::description%]", + "name": "[%key:component::sonarr::services::get_diskspace::fields::entry_id::name%]" + } + }, + "name": "Get series" + }, + "get_upcoming": { + "description": "Gets upcoming episodes from the calendar.", + "fields": { + "days": { + "description": "Number of days to look ahead for upcoming episodes.", + "name": "Days" + }, + "entry_id": { + "description": "[%key:component::sonarr::services::get_diskspace::fields::entry_id::description%]", + "name": "[%key:component::sonarr::services::get_diskspace::fields::entry_id::name%]" + } + }, + "name": "Get upcoming" + }, + "get_wanted": { + "description": "Gets wanted/missing episodes that are being searched for.", + "fields": { + "entry_id": { + "description": "[%key:component::sonarr::services::get_diskspace::fields::entry_id::description%]", + "name": "[%key:component::sonarr::services::get_diskspace::fields::entry_id::name%]" + }, + "max_items": { + "description": "[%key:component::sonarr::services::get_queue::fields::max_items::description%]", + "name": "[%key:component::sonarr::services::get_queue::fields::max_items::name%]" + } + }, + "name": "Get wanted" + } } } diff --git a/homeassistant/components/songpal/manifest.json b/homeassistant/components/songpal/manifest.json index d9794a69e05b92..e99d4f3e2e31f8 100644 --- a/homeassistant/components/songpal/manifest.json +++ b/homeassistant/components/songpal/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@rytilahti", "@shenxn"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/songpal", + "integration_type": "device", "iot_class": "local_push", "loggers": ["songpal"], "requirements": ["python-songpal==0.16.2"], diff --git a/homeassistant/components/songpal/media_player.py b/homeassistant/components/songpal/media_player.py index 413700edef73c7..1bde8a40c70b2c 100644 --- a/homeassistant/components/songpal/media_player.py +++ b/homeassistant/components/songpal/media_player.py @@ -288,6 +288,8 @@ async def async_update(self) -> None: self._volume_min = volume.minVolume self._volume = volume.volume self._volume_control = volume + if self._volume_max: + self._attr_volume_step = 1 / self._volume_max self._attr_is_volume_muted = self._volume_control.is_muted status = await self._dev.get_power() @@ -381,14 +383,6 @@ async def async_set_volume_level(self, volume: float) -> None: _LOGGER.debug("Setting volume to %s", volume) return await self._volume_control.set_volume(volume) - async def async_volume_up(self) -> None: - """Set volume up.""" - return await self._volume_control.set_volume(self._volume + 1) - - async def async_volume_down(self) -> None: - """Set volume down.""" - return await self._volume_control.set_volume(self._volume - 1) - async def async_turn_on(self) -> None: """Turn the device on.""" try: diff --git a/homeassistant/components/sonos/media_browser.py b/homeassistant/components/sonos/media_browser.py index 16250477749d23..768aaf529a1835 100644 --- a/homeassistant/components/sonos/media_browser.py +++ b/homeassistant/components/sonos/media_browser.py @@ -330,7 +330,7 @@ async def root_payload( media_class=MediaClass.DIRECTORY, media_content_id="", media_content_type="favorites", - thumbnail="https://brands.home-assistant.io/_/sonos/logo.png", + thumbnail="/api/brands/integration/sonos/logo.png", can_play=False, can_expand=True, ) @@ -345,7 +345,7 @@ async def root_payload( media_class=MediaClass.DIRECTORY, media_content_id="", media_content_type="library", - thumbnail="https://brands.home-assistant.io/_/sonos/logo.png", + thumbnail="/api/brands/integration/sonos/logo.png", can_play=False, can_expand=True, ) @@ -358,7 +358,7 @@ async def root_payload( media_class=MediaClass.APP, media_content_id="", media_content_type="plex", - thumbnail="https://brands.home-assistant.io/_/plex/logo.png", + thumbnail="/api/brands/integration/plex/logo.png", can_play=False, can_expand=True, ) @@ -585,10 +585,30 @@ def get_media( item_id = "A:ALBUMARTIST/" + "/".join(item_id.split("/")[2:]) if item_id.startswith("A:ALBUM/") or search_type == "tracks": - search_term = urllib.parse.unquote(item_id.split("/")[-1]) + # Some Sonos libraries return album ids in the shape: + # A:ALBUM//, where the artist part disambiguates results. + # Use the album segment for searching. + if item_id.startswith("A:ALBUM/"): + splits = item_id.split("/") + search_term = urllib.parse.unquote(splits[1]) if len(splits) > 1 else "" + album_title: str | None = search_term + else: + search_term = urllib.parse.unquote(item_id.split("/")[-1]) + album_title = None + matches = media_library.get_music_library_information( search_type, search_term=search_term, full_album_art_uri=True ) + if item_id.startswith("A:ALBUM/") and len(matches) > 1: + if result := next( + (item for item in matches if item_id == item.item_id), None + ): + matches = [result] + elif album_title: + if result := next( + (item for item in matches if album_title == item.title), None + ): + matches = [result] elif search_type == SONOS_SHARE: # In order to get the MusicServiceItem, we browse the parent folder # and find one that matches on item_id. diff --git a/homeassistant/components/sony_projector/switch.py b/homeassistant/components/sony_projector/switch.py index c4d993cc22aa69..7aa76245aec94c 100644 --- a/homeassistant/components/sony_projector/switch.py +++ b/homeassistant/components/sony_projector/switch.py @@ -12,7 +12,7 @@ PLATFORM_SCHEMA as SWITCH_PLATFORM_SCHEMA, SwitchEntity, ) -from homeassistant.const import CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON +from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -58,46 +58,24 @@ class SonyProjector(SwitchEntity): def __init__(self, sdcp_connection, name): """Init of the Sony projector.""" self._sdcp = sdcp_connection - self._name = name - self._state = None - self._available = False - self._attributes = {} - - @property - def available(self) -> bool: - """Return if projector is available.""" - return self._available - - @property - def name(self): - """Return name of the projector.""" - return self._name - - @property - def is_on(self): - """Return if the projector is turned on.""" - return self._state - - @property - def extra_state_attributes(self): - """Return state attributes.""" - return self._attributes + self._attr_available = False + self._attr_name = name def update(self) -> None: """Get the latest state from the projector.""" try: - self._state = self._sdcp.get_power() - self._available = True + self._attr_is_on = self._sdcp.get_power() + self._attr_available = True except ConnectionRefusedError: _LOGGER.error("Projector connection refused") - self._available = False + self._attr_available = False def turn_on(self, **kwargs: Any) -> None: """Turn the projector on.""" _LOGGER.debug("Powering on projector '%s'", self.name) if self._sdcp.set_power(True): _LOGGER.debug("Powered on successfully") - self._state = STATE_ON + self._attr_is_on = True else: _LOGGER.error("Power on command was not successful") @@ -106,6 +84,6 @@ def turn_off(self, **kwargs: Any) -> None: _LOGGER.debug("Powering off projector '%s'", self.name) if self._sdcp.set_power(False): _LOGGER.debug("Powered off successfully") - self._state = STATE_OFF + self._attr_is_on = False else: _LOGGER.error("Power off command was not successful") diff --git a/homeassistant/components/soundtouch/manifest.json b/homeassistant/components/soundtouch/manifest.json index 0d8349d1eae8b2..5fc7a771d70942 100644 --- a/homeassistant/components/soundtouch/manifest.json +++ b/homeassistant/components/soundtouch/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@kroimon"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/soundtouch", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["libsoundtouch"], "requirements": ["libsoundtouch==0.8"], diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index c540b8dfd6446b..02c0d8a1bbf9ff 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -333,9 +333,9 @@ def add_zone_slave(self, slaves): self._device.add_zone_slave([slave.device for slave in slaves]) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return entity specific state attributes.""" - attributes = {} + attributes: dict[str, Any] = {} if self._zone and "master" in self._zone: attributes[ATTR_SOUNDTOUCH_ZONE] = self._zone diff --git a/homeassistant/components/spaceapi/quality_scale.yaml b/homeassistant/components/spaceapi/quality_scale.yaml new file mode 100644 index 00000000000000..8791627d97d47f --- /dev/null +++ b/homeassistant/components/spaceapi/quality_scale.yaml @@ -0,0 +1,120 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration has no custom service actions. + appropriate-polling: + status: exempt + comment: This integration does not poll. + brands: done + common-modules: + status: exempt + comment: This integration has no entities and no coordinator. + config-flow-test-coverage: todo + config-flow: todo + dependency-transparency: + status: exempt + comment: This integration has no dependencies. + docs-actions: + status: exempt + comment: This integration has no custom service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: + status: exempt + comment: This integration has no entities. + entity-unique-id: + status: exempt + comment: This integration has no entities. + has-entity-name: + status: exempt + comment: This integration has no entities. + runtime-data: todo + test-before-configure: todo + test-before-setup: todo + unique-config-entry: todo + + # Silver + action-exceptions: + status: exempt + comment: This integration has no custom service actions. + config-entry-unloading: todo + docs-configuration-parameters: todo + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: This integration has no entities. + integration-owner: done + log-when-unavailable: + status: exempt + comment: This integration has no entities. + parallel-updates: + status: exempt + comment: This integration does not poll. + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: + status: exempt + comment: This integration has no entities. + diagnostics: todo + discovery-update-info: + status: exempt + comment: This integration is a service and has no devices. + discovery: + status: exempt + comment: This integration is a service and has no devices. + docs-data-update: + status: exempt + comment: This integration does not poll. + docs-examples: + status: exempt + comment: This integration does not provide any automation + docs-known-limitations: + status: done + comment: Only SpaceAPI v13 is supported. + docs-supported-devices: + status: exempt + comment: This integration is a service and has no devices. + docs-supported-functions: + status: exempt + comment: This integration has no entities. + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: This integration is a service and has no devices. + entity-category: + status: exempt + comment: This integration has no entities. + entity-device-class: + status: exempt + comment: This integration has no entities. + entity-disabled-by-default: + status: exempt + comment: This integration has no entities. + entity-translations: + status: exempt + comment: This integration has no entities. + exception-translations: + status: exempt + comment: This integration has no custom exceptions. + icon-translations: + status: exempt + comment: This integration does not use icons. + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: exempt + comment: This integration is a service and has no devices. + + # Platinum + async-dependency: + status: exempt + comment: This integration has no dependencies. + inject-websession: + status: exempt + comment: This integration does not use web sessions. + strict-typing: done diff --git a/homeassistant/components/splunk/__init__.py b/homeassistant/components/splunk/__init__.py index 451a39b2d8b08b..3838957d81dfee 100644 --- a/homeassistant/components/splunk/__init__.py +++ b/homeassistant/components/splunk/__init__.py @@ -178,25 +178,36 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: connectivity=False, token=True, busy=False ) except ClientConnectionError as err: + _LOGGER.debug("Connection error during setup at %s:%s: %s", host, port, err) raise ConfigEntryNotReady( - f"Connection error connecting to Splunk at {host}:{port}: {err}" + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"host": host, "port": str(port)}, ) from err except TimeoutError as err: + _LOGGER.debug("Timeout during setup at %s:%s: %s", host, port, err) raise ConfigEntryNotReady( - f"Timeout connecting to Splunk at {host}:{port}" + translation_domain=DOMAIN, + translation_key="timeout_connect", + translation_placeholders={"host": host, "port": str(port)}, ) from err except Exception as err: - _LOGGER.exception("Unexpected error setting up Splunk") + _LOGGER.exception("Unexpected setup error at %s:%s", host, port) raise ConfigEntryNotReady( - f"Unexpected error connecting to Splunk: {err}" + translation_domain=DOMAIN, + translation_key="unexpected_connect_error", ) from err if not connectivity_ok: raise ConfigEntryNotReady( - f"Unable to connect to Splunk instance at {host}:{port}" + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"host": host, "port": str(port)}, ) if not token_ok: - raise ConfigEntryAuthFailed("Invalid Splunk token - please reauthenticate") + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="invalid_auth" + ) # Send startup event payload: dict[str, Any] = { diff --git a/homeassistant/components/splunk/config_flow.py b/homeassistant/components/splunk/config_flow.py index 7a2e98a7815532..6f84f9fab5d412 100644 --- a/homeassistant/components/splunk/config_flow.py +++ b/homeassistant/components/splunk/config_flow.py @@ -85,6 +85,40 @@ async def async_step_import( data=import_config, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the Splunk integration.""" + errors: dict[str, str] = {} + + if user_input is not None: + errors = await self._async_validate_input(user_input) + + if not errors: + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates=user_input, + title=f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}", + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_TOKEN): str, + vol.Required(CONF_HOST): str, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, + vol.Optional(CONF_SSL, default=False): bool, + vol.Optional(CONF_VERIFY_SSL, default=True): bool, + vol.Optional(CONF_NAME): str, + } + ), + self._get_reconfigure_entry().data, + ), + errors=errors, + ) + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: diff --git a/homeassistant/components/splunk/diagnostics.py b/homeassistant/components/splunk/diagnostics.py new file mode 100644 index 00000000000000..d9086924bdcc2b --- /dev/null +++ b/homeassistant/components/splunk/diagnostics.py @@ -0,0 +1,25 @@ +"""Diagnostics support for Splunk.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN +from homeassistant.core import HomeAssistant + +from . import DATA_FILTER + +TO_REDACT = {CONF_TOKEN} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: ConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return { + "entry_data": async_redact_data(dict(entry.data), TO_REDACT), + "entity_filter": hass.data[DATA_FILTER].config, + } diff --git a/homeassistant/components/splunk/manifest.json b/homeassistant/components/splunk/manifest.json index 6407feff8b8799..a7bb5a2820bcb8 100644 --- a/homeassistant/components/splunk/manifest.json +++ b/homeassistant/components/splunk/manifest.json @@ -4,9 +4,10 @@ "codeowners": ["@Bre77"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/splunk", + "integration_type": "service", "iot_class": "local_push", "loggers": ["hass_splunk"], - "quality_scale": "legacy", - "requirements": ["hass-splunk==0.1.1"], + "quality_scale": "bronze", + "requirements": ["hass-splunk==0.1.4"], "single_config_entry": true } diff --git a/homeassistant/components/splunk/quality_scale.yaml b/homeassistant/components/splunk/quality_scale.yaml new file mode 100644 index 00000000000000..157153da610396 --- /dev/null +++ b/homeassistant/components/splunk/quality_scale.yaml @@ -0,0 +1,116 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + Integration does not provide custom actions. + appropriate-polling: + status: exempt + comment: | + Event-driven push integration that listens to state changes, no polling occurs. + brands: done + common-modules: done + config-entry-unloading: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + Integration does not provide custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Integration does not create entities. + entity-unique-id: + status: exempt + comment: | + Integration does not create entities. + has-entity-name: + status: exempt + comment: | + Integration does not create entities. + integration-owner: done + reauthentication-flow: done + runtime-data: + status: exempt + comment: | + Integration has no per-entry runtime state to store. The only data in + hass.data is a YAML entity filter bridged from async_setup to + async_setup_entry; no platforms or other code accesses it afterward. + test-before-configure: done + test-before-setup: done + test-coverage: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + Integration does not provide custom actions. + docs-configuration-parameters: + status: exempt + comment: | + Integration does not have an options flow. + docs-installation-parameters: + status: todo + comment: | + Verify docs describe all config flow parameters including host, port, token, SSL settings, and entity filter. The strings.json has good data_description fields that should be reflected in documentation. + entity-unavailable: + status: exempt + comment: | + Integration does not create entities. + log-when-unavailable: + status: exempt + comment: | + Integration does not create entities. + parallel-updates: + status: exempt + comment: | + Integration does not create entities. + + # Gold + diagnostics: done + discovery: + status: exempt + comment: | + Integration does not support automatic discovery. + devices: + status: exempt + comment: | + Integration does not create devices. + entity-category: + status: exempt + comment: | + Integration does not create entities. + entity-device-class: + status: exempt + comment: | + Integration does not create entities. + entity-disabled-by-default: + status: exempt + comment: | + Integration does not create entities. + entity-translations: + status: exempt + comment: | + Integration does not create entities. + exception-translations: done + icon-translations: + status: exempt + comment: | + Integration does not create entities. + reconfiguration-flow: done + # Platinum + async-dependency: + status: todo + comment: | + Verify all methods in hass-splunk library at https://github.com/Bre77/hass_splunk are truly async with no blocking calls or synchronous I/O operations. + inject-websession: done + strict-typing: + status: todo + comment: | + Add py.typed marker to hass-splunk library, add comprehensive type hints to all functions and methods in both library and integration, use custom typed ConfigEntry, add integration to homeassistant/components/.strict-typing file, and verify mypy passes. This should be done after runtime-data is implemented. diff --git a/homeassistant/components/splunk/strings.json b/homeassistant/components/splunk/strings.json index abb2bd5344503f..cd5068317f4407 100644 --- a/homeassistant/components/splunk/strings.json +++ b/homeassistant/components/splunk/strings.json @@ -6,6 +6,7 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_config": "The YAML configuration is invalid and cannot be imported. Please check your configuration.yaml file.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, @@ -20,9 +21,31 @@ "data": { "token": "HTTP Event Collector token" }, + "data_description": { + "token": "The HEC token configured in your Splunk instance" + }, "description": "The Splunk token is no longer valid. Please enter a new HTTP Event Collector token.", "title": "Reauthenticate Splunk" }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "name": "[%key:common::config_flow::data::name%]", + "port": "[%key:common::config_flow::data::port%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "token": "[%key:component::splunk::config::step::user::data::token%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "[%key:component::splunk::config::step::user::data_description::host%]", + "name": "[%key:component::splunk::config::step::user::data_description::name%]", + "port": "[%key:component::splunk::config::step::user::data_description::port%]", + "ssl": "[%key:component::splunk::config::step::user::data_description::ssl%]", + "token": "[%key:component::splunk::config::step::user::data_description::token%]", + "verify_ssl": "[%key:component::splunk::config::step::user::data_description::verify_ssl%]" + }, + "description": "Update your Splunk HTTP Event Collector connection settings." + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", @@ -45,6 +68,20 @@ } } }, + "exceptions": { + "cannot_connect": { + "message": "Unable to connect to Splunk at {host}:{port}." + }, + "invalid_auth": { + "message": "[%key:common::config_flow::error::invalid_auth%]" + }, + "timeout_connect": { + "message": "Connection to Splunk at {host}:{port} timed out." + }, + "unexpected_connect_error": { + "message": "Unexpected error while connecting to Splunk." + } + }, "issues": { "deprecated_yaml_import_issue_cannot_connect": { "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release.\n\nWhile importing your configuration, a connection error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the connection settings from your `{domain}:` configuration and configure the integration via the UI.\n\nNote: Entity filtering via YAML (`filter:`) will continue to work.", diff --git a/homeassistant/components/spotify/__init__.py b/homeassistant/components/spotify/__init__.py index aee7a1a62df846..fc81dd9ef01c8a 100644 --- a/homeassistant/components/spotify/__init__.py +++ b/homeassistant/components/spotify/__init__.py @@ -2,11 +2,10 @@ from __future__ import annotations -from datetime import timedelta from typing import TYPE_CHECKING import aiohttp -from spotifyaio import Device, SpotifyClient, SpotifyConnectionError +from spotifyaio import SpotifyClient from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant @@ -17,12 +16,15 @@ OAuth2Session, async_get_config_entry_implementation, ) -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .browse_media import async_browse_media -from .const import DOMAIN, LOGGER, SPOTIFY_SCOPES -from .coordinator import SpotifyConfigEntry, SpotifyCoordinator -from .models import SpotifyData +from .const import DOMAIN, SPOTIFY_SCOPES +from .coordinator import ( + SpotifyConfigEntry, + SpotifyCoordinator, + SpotifyData, + SpotifyDeviceCoordinator, +) from .util import ( is_spotify_media_type, resolve_spotify_media_type, @@ -73,20 +75,7 @@ async def _refresh_token() -> str: await coordinator.async_config_entry_first_refresh() - async def _update_devices() -> list[Device]: - try: - return await spotify.get_devices() - except SpotifyConnectionError as err: - raise UpdateFailed from err - - device_coordinator: DataUpdateCoordinator[list[Device]] = DataUpdateCoordinator( - hass, - LOGGER, - name=f"{entry.title} Devices", - config_entry=entry, - update_interval=timedelta(minutes=5), - update_method=_update_devices, - ) + device_coordinator = SpotifyDeviceCoordinator(hass, entry, spotify) await device_coordinator.async_config_entry_first_refresh() entry.runtime_data = SpotifyData(coordinator, session, device_coordinator) diff --git a/homeassistant/components/spotify/browse_media.py b/homeassistant/components/spotify/browse_media.py index 6ac8729765ad4a..a468a66f12f57a 100644 --- a/homeassistant/components/spotify/browse_media.py +++ b/homeassistant/components/spotify/browse_media.py @@ -118,7 +118,6 @@ class BrowsableMedia(StrEnum): CURRENT_USER_RECENTLY_PLAYED = "current_user_recently_played" CURRENT_USER_TOP_ARTISTS = "current_user_top_artists" CURRENT_USER_TOP_TRACKS = "current_user_top_tracks" - NEW_RELEASES = "new_releases" LIBRARY_MAP = { @@ -130,7 +129,6 @@ class BrowsableMedia(StrEnum): BrowsableMedia.CURRENT_USER_RECENTLY_PLAYED.value: "Recently played", BrowsableMedia.CURRENT_USER_TOP_ARTISTS.value: "Top Artists", BrowsableMedia.CURRENT_USER_TOP_TRACKS.value: "Top Tracks", - BrowsableMedia.NEW_RELEASES.value: "New Releases", } CONTENT_TYPE_MEDIA_CLASS: dict[str, Any] = { @@ -166,10 +164,6 @@ class BrowsableMedia(StrEnum): "parent": MediaClass.DIRECTORY, "children": MediaClass.TRACK, }, - BrowsableMedia.NEW_RELEASES.value: { - "parent": MediaClass.DIRECTORY, - "children": MediaClass.ALBUM, - }, MediaType.PLAYLIST: { "parent": MediaClass.PLAYLIST, "children": MediaClass.TRACK, @@ -212,7 +206,7 @@ async def async_browse_media( media_class=MediaClass.APP, media_content_id=f"{MEDIA_PLAYER_PREFIX}{config_entry.entry_id}", media_content_type=f"{MEDIA_PLAYER_PREFIX}library", - thumbnail="https://brands.home-assistant.io/_/spotify/logo.png", + thumbnail="/api/brands/integration/spotify/logo.png", can_play=False, can_expand=True, ) @@ -223,7 +217,7 @@ async def async_browse_media( media_class=MediaClass.APP, media_content_id=MEDIA_PLAYER_PREFIX, media_content_type="spotify", - thumbnail="https://brands.home-assistant.io/_/spotify/logo.png", + thumbnail="/api/brands/integration/spotify/logo.png", can_play=False, can_expand=True, children=children, @@ -356,14 +350,11 @@ async def build_item_response( # noqa: C901 elif media_content_type == BrowsableMedia.CURRENT_USER_TOP_TRACKS: if top_tracks := await spotify.get_top_tracks(): items = [_get_track_item_payload(track) for track in top_tracks] - elif media_content_type == BrowsableMedia.NEW_RELEASES: - if new_releases := await spotify.get_new_releases(): - items = [_get_album_item_payload(album) for album in new_releases] elif media_content_type == MediaType.PLAYLIST: if playlist := await spotify.get_playlist(media_content_id): title = playlist.name image = playlist.images[0].url if playlist.images else None - for playlist_item in playlist.tracks.items: + for playlist_item in playlist.items.items: if playlist_item.track.type is ItemType.TRACK: if TYPE_CHECKING: assert isinstance(playlist_item.track, Track) diff --git a/homeassistant/components/spotify/config_flow.py b/homeassistant/components/spotify/config_flow.py index 3478887d64c3ad..1fc19515318b33 100644 --- a/homeassistant/components/spotify/config_flow.py +++ b/homeassistant/components/spotify/config_flow.py @@ -6,7 +6,7 @@ import logging from typing import Any -from spotifyaio import SpotifyClient +from spotifyaio import SpotifyClient, SpotifyForbiddenError from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, CONF_TOKEN @@ -41,6 +41,9 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResu try: current_user = await spotify.get_current_user() + except SpotifyForbiddenError: + self.logger.exception("User is not subscribed to Spotify") + return self.async_abort(reason="user_not_premium") except Exception: self.logger.exception("Error while connecting to Spotify") return self.async_abort(reason="connection_error") diff --git a/homeassistant/components/spotify/coordinator.py b/homeassistant/components/spotify/coordinator.py index 2d5fffebb7bdd9..6fdaff48a65e99 100644 --- a/homeassistant/components/spotify/coordinator.py +++ b/homeassistant/components/spotify/coordinator.py @@ -3,36 +3,51 @@ from dataclasses import dataclass from datetime import datetime, timedelta import logging -from typing import TYPE_CHECKING from spotifyaio import ( ContextType, + Device, PlaybackState, Playlist, SpotifyClient, SpotifyConnectionError, + SpotifyForbiddenError, SpotifyNotFoundError, UserProfile, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util from .const import DOMAIN -if TYPE_CHECKING: - from .models import SpotifyData - _LOGGER = logging.getLogger(__name__) type SpotifyConfigEntry = ConfigEntry[SpotifyData] +@dataclass +class SpotifyData: + """Class to hold Spotify data.""" + + coordinator: SpotifyCoordinator + session: OAuth2Session + devices: SpotifyDeviceCoordinator + + UPDATE_INTERVAL = timedelta(seconds=30) +FREE_API_BLOGPOST = ( + "https://developer.spotify.com/blog/" + "2026-02-06-update-on-developer-access-and-platform-security" +) + @dataclass class SpotifyCoordinatorData: @@ -78,6 +93,19 @@ async def _async_setup(self) -> None: """Set up the coordinator.""" try: self.current_user = await self.client.get_current_user() + except SpotifyForbiddenError as err: + async_create_issue( + self.hass, + DOMAIN, + f"user_not_premium_{self.config_entry.unique_id}", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.ERROR, + translation_key="user_not_premium", + translation_placeholders={"entry_title": self.config_entry.title}, + learn_more_url=FREE_API_BLOGPOST, + ) + raise ConfigEntryError("User is not subscribed to Spotify") from err except SpotifyConnectionError as err: raise UpdateFailed("Error communicating with Spotify API") from err @@ -143,3 +171,31 @@ async def _async_update_data(self) -> SpotifyCoordinatorData: playlist=self._playlist, dj_playlist=dj_playlist, ) + + +class SpotifyDeviceCoordinator(DataUpdateCoordinator[list[Device]]): + """Class to manage fetching Spotify data.""" + + config_entry: SpotifyConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: SpotifyConfigEntry, + client: SpotifyClient, + ) -> None: + """Initialize.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=f"{config_entry.title} Devices", + update_interval=timedelta(minutes=5), + ) + self._client = client + + async def _async_update_data(self) -> list[Device]: + try: + return await self._client.get_devices() + except SpotifyConnectionError as err: + raise UpdateFailed from err diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index ac7f575bcc5d0b..3bef43b6cdedbc 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -8,5 +8,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["spotifyaio"], - "requirements": ["spotifyaio==1.0.0"] + "requirements": ["spotifyaio==2.0.2"] } diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index a833edadaa3a14..d45d44751a6411 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -9,15 +9,14 @@ from typing import TYPE_CHECKING, Any, Concatenate from spotifyaio import ( - Device, Episode, Item, ItemType, PlaybackState, - ProductType, RepeatMode as SpotifyRepeatMode, Track, ) +from spotifyaio.models import ProductType from yarl import URL from homeassistant.components.media_player import ( @@ -32,7 +31,6 @@ ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .browse_media import async_browse_media_internal from .const import ( @@ -40,7 +38,11 @@ MEDIA_TYPE_USER_SAVED_TRACKS, PLAYABLE_MEDIA_TYPES, ) -from .coordinator import SpotifyConfigEntry, SpotifyCoordinator +from .coordinator import ( + SpotifyConfigEntry, + SpotifyCoordinator, + SpotifyDeviceCoordinator, +) from .entity import SpotifyEntity _LOGGER = logging.getLogger(__name__) @@ -122,7 +124,7 @@ class SpotifyMediaPlayer(SpotifyEntity, MediaPlayerEntity): def __init__( self, coordinator: SpotifyCoordinator, - device_coordinator: DataUpdateCoordinator[list[Device]], + device_coordinator: SpotifyDeviceCoordinator, ) -> None: """Initialize.""" super().__init__(coordinator) @@ -222,7 +224,7 @@ def media_artist(self, item: Item) -> str: # noqa: PLR0206 if item.type == ItemType.EPISODE: if TYPE_CHECKING: assert isinstance(item, Episode) - return item.show.publisher + return item.show.name if TYPE_CHECKING: assert isinstance(item, Track) @@ -230,12 +232,10 @@ def media_artist(self, item: Item) -> str: # noqa: PLR0206 @property @ensure_item - def media_album_name(self, item: Item) -> str: # noqa: PLR0206 + def media_album_name(self, item: Item) -> str | None: # noqa: PLR0206 """Return the media album.""" if item.type == ItemType.EPISODE: - if TYPE_CHECKING: - assert isinstance(item, Episode) - return item.show.name + return None if TYPE_CHECKING: assert isinstance(item, Track) diff --git a/homeassistant/components/spotify/models.py b/homeassistant/components/spotify/models.py deleted file mode 100644 index ca323267f79e83..00000000000000 --- a/homeassistant/components/spotify/models.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Models for use in Spotify integration.""" - -from dataclasses import dataclass - -from spotifyaio import Device - -from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -from .coordinator import SpotifyCoordinator - - -@dataclass -class SpotifyData: - """Class to hold Spotify data.""" - - coordinator: SpotifyCoordinator - session: OAuth2Session - devices: DataUpdateCoordinator[list[Device]] diff --git a/homeassistant/components/spotify/strings.json b/homeassistant/components/spotify/strings.json index 13dca5db7db7a9..c76544ab7a7479 100644 --- a/homeassistant/components/spotify/strings.json +++ b/homeassistant/components/spotify/strings.json @@ -12,7 +12,8 @@ "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", "reauth_account_mismatch": "The Spotify account authenticated with does not match the account that needed re-authentication.", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "user_not_premium": "The Spotify API has been changed and Developer applications created with a free account can no longer access the API. To continue using the Spotify integration, you should use an Spotify Developer application created with a Spotify Premium account, or upgrade to Spotify Premium." }, "create_entry": { "default": "Successfully authenticated with Spotify." @@ -41,6 +42,12 @@ "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" } }, + "issues": { + "user_not_premium": { + "description": "[%key:component::spotify::config::abort::user_not_premium%]", + "title": "Spotify integration requires a Spotify Premium account" + } + }, "system_health": { "info": { "api_endpoint_reachable": "Spotify API endpoint reachable" diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 244334565657ea..44ee32ec8e8c65 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.0"] + "requirements": ["SQLAlchemy==2.0.41", "sqlparse==0.5.5"] } diff --git a/homeassistant/components/sql/util.py b/homeassistant/components/sql/util.py index 636065e404e7d8..7433462f125a84 100644 --- a/homeassistant/components/sql/util.py +++ b/homeassistant/components/sql/util.py @@ -261,7 +261,7 @@ def check_and_render_sql_query(hass: HomeAssistant, query: Template | str) -> st raise MultipleQueryError("Multiple SQL statements are not allowed") if ( len(rendered_queries) == 0 - or (query_type := rendered_queries[0].get_type()) == "UNKNOWN" + or (query_type := rendered_queries[0].get_type()) == "UNKNOWN" # type: ignore[no-untyped-call] ): raise UnknownQueryTypeError("SQL query is empty or unknown type") if query_type != "SELECT": diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py index c9776b63d9c999..f7c15e648a94c8 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -33,6 +33,7 @@ from .const import ( CONF_BROWSE_LIMIT, CONF_HTTPS, + CONF_SERVER_LIST, CONF_VOLUME_STEP, DEFAULT_BROWSE_LIMIT, DEFAULT_PORT, @@ -45,45 +46,23 @@ TIMEOUT = 5 -def _base_schema( - discovery_info: dict[str, Any] | None = None, -) -> vol.Schema: - """Generate base schema.""" - base_schema: dict[Any, Any] = {} - if discovery_info and CONF_HOST in discovery_info: - base_schema.update( - { - vol.Required( - CONF_HOST, - description={"suggested_value": discovery_info[CONF_HOST]}, - ): str, - } - ) - else: - base_schema.update({vol.Required(CONF_HOST): str}) - - if discovery_info and CONF_PORT in discovery_info: - base_schema.update( - { - vol.Required( - CONF_PORT, - default=DEFAULT_PORT, - description={"suggested_value": discovery_info[CONF_PORT]}, - ): int, - } - ) - else: - base_schema.update({vol.Required(CONF_PORT, default=DEFAULT_PORT): int}) - - base_schema.update( - { - vol.Optional(CONF_USERNAME): str, - vol.Optional(CONF_PASSWORD): str, - vol.Optional(CONF_HTTPS, default=False): bool, - } - ) +FULL_EDIT_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + vol.Optional(CONF_HTTPS, default=False): bool, + } +) - return vol.Schema(base_schema) +SHORT_EDIT_SCHEMA = vol.Schema( + { + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + vol.Optional(CONF_HTTPS, default=False): bool, + } +) class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN): @@ -93,8 +72,9 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize an instance of the squeezebox config flow.""" - self.data_schema = _base_schema() - self.discovery_info: dict[str, Any] | None = None + self.discovery_task: asyncio.Task | None = None + self.discovered_servers: list[dict[str, Any]] = [] + self.chosen_server: dict[str, Any] = {} @staticmethod @callback @@ -102,34 +82,43 @@ def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() - async def _discover(self, uuid: str | None = None) -> None: + async def _discover(self) -> None: """Discover an unconfigured LMS server.""" - self.discovery_info = None - discovery_event = asyncio.Event() + # Reset discovery state to avoid stale or duplicate servers across runs + self.discovered_servers = [] + self.chosen_server = {} + _discovery_task: asyncio.Task | None = None def _discovery_callback(server: Server) -> None: + _discovery_info: dict[str, Any] | None = {} if server.uuid: # ignore already configured uuids for entry in self._async_current_entries(): if entry.unique_id == server.uuid: return - self.discovery_info = { + _discovery_info = { CONF_HOST: server.host, CONF_PORT: int(server.port), "uuid": server.uuid, + "name": server.name, } - _LOGGER.debug("Discovered server: %s", self.discovery_info) - discovery_event.set() - discovery_task = self.hass.async_create_task( + _LOGGER.debug( + "Discovered server: %s, creating discovery_info %s", + server, + _discovery_info, + ) + if _discovery_info not in self.discovered_servers: + self.discovered_servers.append(_discovery_info) + + _discovery_task = self.hass.async_create_task( async_discover(_discovery_callback) ) - await discovery_event.wait() - discovery_task.cancel() # stop searching as soon as we find server + await asyncio.sleep(TIMEOUT) - # update with suggested values from discovery - self.data_schema = _base_schema(self.discovery_info) + _LOGGER.debug("Discovered Servers %s", self.discovered_servers) + _discovery_task.cancel() async def _validate_input(self, data: dict[str, Any]) -> str | None: """Validate the user input allows us to connect. @@ -142,7 +131,7 @@ async def _validate_input(self, data: dict[str, Any]) -> str | None: data[CONF_PORT], data.get(CONF_USERNAME), data.get(CONF_PASSWORD), - https=data[CONF_HTTPS], + https=data.get(CONF_HTTPS, False), ) try: @@ -164,37 +153,107 @@ async def _validate_input(self, data: dict[str, Any]) -> str | None: return None + async def async_step_choose_server( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Choose manual or discover flow.""" + _chosen_host: str + + if user_input: + _chosen_host = user_input[CONF_SERVER_LIST] + for _server in self.discovered_servers: + if _chosen_host == _server[CONF_HOST]: + self.chosen_server[CONF_HOST] = _chosen_host + self.chosen_server[CONF_PORT] = _server[CONF_PORT] + self.chosen_server[CONF_HTTPS] = False + return await self.async_step_edit_discovered() + + _options = { + _server[CONF_HOST]: f"{_server['name']} ({_server[CONF_HOST]})" + for _server in self.discovered_servers + } + return self.async_show_form( + step_id="choose_server", + data_schema=vol.Schema({vol.Required(CONF_SERVER_LIST): vol.In(_options)}), + ) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" - errors = {} - if user_input and CONF_HOST in user_input: - # update with host provided by user - self.data_schema = _base_schema(user_input) - return await self.async_step_edit() - # no host specified, see if we can discover an unconfigured LMS server - try: - async with asyncio.timeout(TIMEOUT): - await self._discover() - return await self.async_step_edit() - except TimeoutError: - errors["base"] = "no_server_found" + return self.async_show_menu( + step_id="user", menu_options=["start_discovery", "edit"] + ) + + async def async_step_discovery_failed( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a failed discovery.""" + + return self.async_show_menu(step_id="discovery_failed", menu_options=["edit"]) + + async def async_step_start_discovery( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + + if not self.discovery_task: + self.discovery_task = self.hass.async_create_task(self._discover()) + + if self.discovery_task.done(): + self.discovery_task.cancel() + self.discovery_task = None + # Sleep to allow task cancellation to complete + + await asyncio.sleep(0.1) + + return self.async_show_progress_done( + next_step_id="choose_server" + if self.discovered_servers + else "discovery_failed" + ) + + return self.async_show_progress( + step_id="start_discovery", + progress_action="start_discovery", + progress_task=self.discovery_task, + ) + + async def async_step_edit( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Edit a discovered or manually inputted server.""" + + errors = {} + if user_input: + error = await self._validate_input(user_input) + if not error: + return self.async_create_entry( + title=user_input[CONF_HOST], data=user_input + ) + errors["base"] = error - # display the form return self.async_show_form( - step_id="user", - data_schema=vol.Schema({vol.Optional(CONF_HOST): str}), + step_id="edit", + data_schema=FULL_EDIT_SCHEMA, errors=errors, ) - async def async_step_edit( + async def async_step_edit_discovered( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Edit a discovered or manually inputted server.""" + + if not (await self._validate_input(self.chosen_server)): + # Attempt to connect with default data successful + return self.async_create_entry( + title=self.chosen_server[CONF_HOST], data=self.chosen_server + ) errors = {} if user_input: + user_input[CONF_HOST] = self.chosen_server[CONF_HOST] + user_input[CONF_PORT] = self.chosen_server[CONF_PORT] error = await self._validate_input(user_input) if not error: return self.async_create_entry( @@ -203,39 +262,68 @@ async def async_step_edit( errors["base"] = error return self.async_show_form( - step_id="edit", data_schema=self.data_schema, errors=errors + step_id="edit_discovered", + description_placeholders={ + "host": self.chosen_server[CONF_HOST], + "port": self.chosen_server[CONF_PORT], + }, + data_schema=SHORT_EDIT_SCHEMA, + errors=errors, + ) + + async def async_step_edit_integration_discovered( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Edit a discovered or manually inputted server.""" + + errors = {} + if user_input: + user_input[CONF_HOST] = self.chosen_server[CONF_HOST] + user_input[CONF_PORT] = self.chosen_server[CONF_PORT] + error = await self._validate_input(user_input) + if not error: + return self.async_create_entry( + title=user_input[CONF_HOST], data=user_input + ) + errors["base"] = error + return self.async_show_form( + step_id="edit_integration_discovered", + description_placeholders={ + "desc": f"LMS Host: {self.chosen_server[CONF_HOST]}, Port: {self.chosen_server[CONF_PORT]}" + }, + data_schema=SHORT_EDIT_SCHEMA, + errors=errors, ) async def async_step_integration_discovery( - self, discovery_info: dict[str, Any] + self, _discovery_info: dict[str, Any] ) -> ConfigFlowResult: """Handle discovery of a server.""" - _LOGGER.debug("Reached server discovery flow with info: %s", discovery_info) - if "uuid" in discovery_info: - await self.async_set_unique_id(discovery_info.pop("uuid")) + _LOGGER.debug("Reached server discovery flow with info: %s", _discovery_info) + if "uuid" in _discovery_info: + await self.async_set_unique_id(_discovery_info.pop("uuid")) self._abort_if_unique_id_configured() else: # attempt to connect to server and determine uuid. will fail if # password required - error = await self._validate_input(discovery_info) + error = await self._validate_input(_discovery_info) if error: await self._async_handle_discovery_without_unique_id() - # update schema with suggested values from discovery - self.data_schema = _base_schema(discovery_info) - - self.context.update({"title_placeholders": {"host": discovery_info[CONF_HOST]}}) - - return await self.async_step_edit() + self.context.update( + {"title_placeholders": {"host": _discovery_info[CONF_HOST]}} + ) + self.chosen_server = _discovery_info + return await self.async_step_edit_integration_discovered() async def async_step_dhcp( - self, discovery_info: DhcpServiceInfo + self, _discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery of a Squeezebox player.""" _LOGGER.debug( - "Reached dhcp discovery of a player with info: %s", discovery_info + "Reached dhcp discovery of a player with info: %s", _discovery_info ) - await self.async_set_unique_id(format_mac(discovery_info.macaddress)) + await self.async_set_unique_id(format_mac(_discovery_info.macaddress)) self._abort_if_unique_id_configured() _LOGGER.debug("Configuring dhcp player with unique id: %s", self.unique_id) diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py index 6d8ecd0152f5b4..d1e80e4a48fede 100644 --- a/homeassistant/components/squeezebox/const.py +++ b/homeassistant/components/squeezebox/const.py @@ -56,3 +56,4 @@ ATTR_URL = "url" UPDATE_PLUGINS_RELEASE_SUMMARY = "update_plugins_release_summary" UPDATE_RELEASE_SUMMARY = "update_release_summary" +CONF_SERVER_LIST = "server_list" diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json index 7fe3cc786b2c14..acca0cbdbda586 100644 --- a/homeassistant/components/squeezebox/strings.json +++ b/homeassistant/components/squeezebox/strings.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "no_server_found": "No LMS found." }, "error": { @@ -12,7 +13,27 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "flow_title": "{host}", + "progress": { + "start_discovery": "Attempting to discover new LMS servers\n\nThis will take about 5 seconds", + "title": "LMS discovery" + }, "step": { + "choose_server": { + "data": { + "server_list": "Server list" + }, + "data_description": { + "server_list": "Choose the server to configure." + }, + "title": "Discovered servers" + }, + "discovery_failed": { + "description": "No LMS were discovered on the network.", + "menu_options": { + "edit": "Enter configuration manually" + }, + "title": "Discovery failed" + }, "edit": { "data": { "host": "[%key:common::config_flow::data::host%]", @@ -22,21 +43,47 @@ "username": "[%key:common::config_flow::data::username%]" }, "data_description": { - "host": "[%key:component::squeezebox::config::step::user::data_description::host%]", + "host": "The IP address or hostname of the LMS.", "https": "Connect to the LMS over HTTPS (requires reverse proxy).", "password": "The password from LMS Advanced Security (if defined).", "port": "The web interface port on the LMS. The default is 9000.", "username": "The username from LMS Advanced Security (if defined)." + } + }, + "edit_discovered": { + "data": { + "https": "Connect over HTTPS (requires reverse proxy)", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" }, - "title": "Edit connection information" + "data_description": { + "https": "Connect to the LMS over HTTPS (requires reverse proxy).", + "password": "The password from LMS Advanced Security (if defined).", + "username": "The username from LMS Advanced Security (if defined)." + }, + "description": "LMS Host: {host}, Port {port}", + "title": "Edit additional connection information" }, - "user": { + "edit_integration_discovered": { "data": { - "host": "[%key:common::config_flow::data::host%]" + "https": "Connect over HTTPS (requires reverse proxy)", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" }, "data_description": { - "host": "The hostname or IP address of your Lyrion Music Server." - } + "https": "Connect to the LMS over HTTPS (requires reverse proxy).", + "password": "The password from LMS Advanced Security (if defined).", + "username": "The username from LMS Advanced Security (if defined)." + }, + "description": "{desc}", + "title": "Edit additional connection information" + }, + "user": { + "menu_options": { + "edit": "Enter configuration manually", + "start_discovery": "Discover new LMS" + }, + "title": "LMS configuration" } } }, @@ -260,11 +307,11 @@ "description": "Calls a custom Squeezebox JSONRPC API.", "fields": { "command": { - "description": "Command to pass to Lyrion Music Server (p0 in the CLI documentation).", + "description": "Command to pass to LMS (p0 in the CLI documentation).", "name": "Command" }, "parameters": { - "description": "Array of additional parameters to pass to Lyrion Music Server (p1, ..., pN in the CLI documentation).", + "description": "Array of additional parameters to pass to LMS (p1, ..., pN in the CLI documentation).", "name": "Parameters" } }, diff --git a/homeassistant/components/srp_energy/manifest.json b/homeassistant/components/srp_energy/manifest.json index e2571368789b83..27deb87b0ca1da 100644 --- a/homeassistant/components/srp_energy/manifest.json +++ b/homeassistant/components/srp_energy/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@briglx"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/srp_energy", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["srpenergy"], "requirements": ["srpenergy==1.3.6"] diff --git a/homeassistant/components/starline/binary_sensor.py b/homeassistant/components/starline/binary_sensor.py index a570b26a0d1ade..faec8974ed1ca2 100644 --- a/homeassistant/components/starline/binary_sensor.py +++ b/homeassistant/components/starline/binary_sensor.py @@ -100,6 +100,6 @@ def __init__( self.entity_description = description @property - def is_on(self): + def is_on(self) -> bool | None: """Return the state of the binary sensor.""" return self._device.car_state.get(self._key) diff --git a/homeassistant/components/starline/manifest.json b/homeassistant/components/starline/manifest.json index 5b15445c004f38..31f3641592ecf0 100644 --- a/homeassistant/components/starline/manifest.json +++ b/homeassistant/components/starline/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@anonym-tsk"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/starline", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["starline"], "requirements": ["starline==0.1.5"] diff --git a/homeassistant/components/starline/sensor.py b/homeassistant/components/starline/sensor.py index 7c3690837f1edb..5fff61144dc3a7 100644 --- a/homeassistant/components/starline/sensor.py +++ b/homeassistant/components/starline/sensor.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -118,7 +120,7 @@ def __init__( self.entity_description = description @property - def icon(self): + def icon(self) -> str | None: """Icon to use in the frontend, if any.""" if self._key == "battery": return icon_for_battery_level( @@ -166,7 +168,7 @@ def native_unit_of_measurement(self): return self.entity_description.native_unit_of_measurement @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes of the sensor.""" if self._key == "balance": return self._account.balance_attrs(self._device) diff --git a/homeassistant/components/starline/switch.py b/homeassistant/components/starline/switch.py index 79d4fa86ddfdb6..3a457c6ffdee45 100644 --- a/homeassistant/components/starline/switch.py +++ b/homeassistant/components/starline/switch.py @@ -71,15 +71,7 @@ def available(self) -> bool: return super().available and self._device.online @property - def extra_state_attributes(self): - """Return the state attributes of the switch.""" - if self._key == "ign": - # Deprecated and should be removed in 2025.8 - return self._account.engine_attrs(self._device) - return None - - @property - def is_on(self): + def is_on(self) -> bool | None: """Return True if entity is on.""" return self._device.car_state.get(self._key) diff --git a/homeassistant/components/starlink/manifest.json b/homeassistant/components/starlink/manifest.json index cc787076e7a6f0..9eb8f4d2cc55af 100644 --- a/homeassistant/components/starlink/manifest.json +++ b/homeassistant/components/starlink/manifest.json @@ -1,9 +1,10 @@ { "domain": "starlink", "name": "Starlink", - "codeowners": ["@boswelja"], + "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/starlink", + "integration_type": "device", "iot_class": "local_polling", "requirements": ["starlink-grpc-core==1.2.3"] } diff --git a/homeassistant/components/startca/sensor.py b/homeassistant/components/startca/sensor.py index 62e02426fcb2b4..9b92780374981b 100644 --- a/homeassistant/components/startca/sensor.py +++ b/homeassistant/components/startca/sensor.py @@ -8,6 +8,7 @@ import logging from xml.parsers.expat import ExpatError +from aiohttp import ClientSession import voluptuous as vol import xmltodict @@ -150,7 +151,7 @@ async def async_setup_platform( apikey = config[CONF_API_KEY] bandwidthcap = config[CONF_TOTAL_BANDWIDTH] - ts_data = StartcaData(hass.loop, websession, apikey, bandwidthcap) + ts_data = StartcaData(websession, apikey, bandwidthcap) ret = await ts_data.async_update() if ret is False: _LOGGER.error("Invalid Start.ca API key: %s", apikey) @@ -176,7 +177,9 @@ async def async_setup_platform( class StartcaSensor(SensorEntity): """Representation of Start.ca Bandwidth sensor.""" - def __init__(self, startcadata, name, description: SensorEntityDescription) -> None: + def __init__( + self, startcadata: StartcaData, name: str, description: SensorEntityDescription + ) -> None: """Initialize the sensor.""" self.entity_description = description self.startcadata = startcadata @@ -194,9 +197,10 @@ async def async_update(self) -> None: class StartcaData: """Get data from Start.ca API.""" - def __init__(self, loop, websession, api_key, bandwidth_cap): + def __init__( + self, websession: ClientSession, api_key: str, bandwidth_cap: int + ) -> None: """Initialize the data object.""" - self.loop = loop self.websession = websession self.api_key = api_key self.bandwidth_cap = bandwidth_cap @@ -215,7 +219,7 @@ def bytes_to_gb(value): return float(value) * 10**-9 @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update(self): + async def async_update(self) -> bool: """Get the Start.ca bandwidth data from the web service.""" _LOGGER.debug("Updating Start.ca usage data") url = f"https://www.start.ca/support/usage/api?key={self.api_key}" diff --git a/homeassistant/components/steamist/manifest.json b/homeassistant/components/steamist/manifest.json index cabb8835608a05..c094de9e2459dc 100644 --- a/homeassistant/components/steamist/manifest.json +++ b/homeassistant/components/steamist/manifest.json @@ -14,6 +14,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/steamist", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["aiosteamist", "discovery30303"], "requirements": ["aiosteamist==1.0.1", "discovery30303==0.3.3"] diff --git a/homeassistant/components/stiebel_eltron/manifest.json b/homeassistant/components/stiebel_eltron/manifest.json index f3cfba01e1df2a..f3ff88e0e2b7ee 100644 --- a/homeassistant/components/stiebel_eltron/manifest.json +++ b/homeassistant/components/stiebel_eltron/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@fucm", "@ThyMYthOS"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/stiebel_eltron", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["pymodbus", "pystiebeleltron"], "requirements": ["pystiebeleltron==0.2.5"] diff --git a/homeassistant/components/streamlabswater/manifest.json b/homeassistant/components/streamlabswater/manifest.json index ec076bd52ec217..cde7dcfa9ebae4 100644 --- a/homeassistant/components/streamlabswater/manifest.json +++ b/homeassistant/components/streamlabswater/manifest.json @@ -4,6 +4,7 @@ "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/streamlabswater", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["streamlabswater"], "requirements": ["streamlabswater==1.0.1"] diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py index 25ed29d30719b7..abc828dac3f1be 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -397,11 +397,11 @@ def _metadata_from_header(request: web.Request) -> SpeechMetadata: try: return SpeechMetadata( language=args["language"], - format=args["format"], - codec=args["codec"], - bit_rate=args["bit_rate"], - sample_rate=args["sample_rate"], - channel=args["channel"], + format=AudioFormats(args["format"]), + codec=AudioCodecs(args["codec"]), + bit_rate=AudioBitRates(int(args["bit_rate"])), + sample_rate=AudioSampleRates(int(args["sample_rate"])), + channel=AudioChannels(int(args["channel"])), ) except ValueError as err: raise ValueError(f"Wrong format of X-Speech-Content: {err}") from err diff --git a/homeassistant/components/stt/models.py b/homeassistant/components/stt/models.py index 9471316dc8e920..40b43109778dd9 100644 --- a/homeassistant/components/stt/models.py +++ b/homeassistant/components/stt/models.py @@ -23,12 +23,6 @@ class SpeechMetadata: sample_rate: AudioSampleRates channel: AudioChannels - def __post_init__(self) -> None: - """Finish initializing the metadata.""" - self.bit_rate = AudioBitRates(int(self.bit_rate)) - self.sample_rate = AudioSampleRates(int(self.sample_rate)) - self.channel = AudioChannels(int(self.channel)) - @dataclass class SpeechResult: diff --git a/homeassistant/components/subaru/__init__.py b/homeassistant/components/subaru/__init__.py index 4068507ed148a6..247618a8dcd869 100644 --- a/homeassistant/components/subaru/__init__.py +++ b/homeassistant/components/subaru/__init__.py @@ -1,8 +1,6 @@ """The Subaru integration.""" -from datetime import timedelta import logging -import time from subarulink import Controller as SubaruAPI, InvalidCredentials, SubaruException @@ -18,11 +16,8 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( - CONF_UPDATE_ENABLED, - COORDINATOR_NAME, DOMAIN, ENTRY_CONTROLLER, ENTRY_COORDINATOR, @@ -42,6 +37,7 @@ VEHICLE_NAME, VEHICLE_VIN, ) +from .coordinator import SubaruDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -75,20 +71,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if controller.get_subscription_status(vin): vehicle_info[vin] = get_vehicle_info(controller, vin) - async def async_update_data(): - """Fetch data from API endpoint.""" - try: - return await refresh_subaru_data(entry, vehicle_info, controller) - except SubaruException as err: - raise UpdateFailed(err.message) from err - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name=COORDINATOR_NAME, - update_method=async_update_data, - update_interval=timedelta(seconds=FETCH_INTERVAL), + coordinator = SubaruDataUpdateCoordinator( + hass, entry, controller=controller, vehicle_info=vehicle_info ) await coordinator.async_refresh() @@ -113,41 +97,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def refresh_subaru_data(config_entry, vehicle_info, controller): - """Refresh local data with data fetched via Subaru API. - - Subaru API calls assume a server side vehicle context - Data fetch/update must be done for each vehicle - """ - data = {} - - for vehicle in vehicle_info.values(): - vin = vehicle[VEHICLE_VIN] - - # Optionally send an "update" remote command to vehicle (throttled with update_interval) - if config_entry.options.get(CONF_UPDATE_ENABLED, False): - await update_subaru(vehicle, controller) - - # Fetch data from Subaru servers - await controller.fetch(vin, force=True) - - # Update our local data that will go to entity states - if received_data := await controller.get_data(vin): - data[vin] = received_data - - return data - - -async def update_subaru(vehicle, controller): - """Commands remote vehicle update (polls the vehicle to update subaru API cache).""" - cur_time = time.time() - last_update = vehicle[VEHICLE_LAST_UPDATE] - - if cur_time - last_update > controller.get_update_interval(): - await controller.update(vehicle[VEHICLE_VIN], force=True) - vehicle[VEHICLE_LAST_UPDATE] = cur_time - - def get_vehicle_info(controller, vin): """Obtain vehicle identifiers and capabilities.""" return { diff --git a/homeassistant/components/subaru/coordinator.py b/homeassistant/components/subaru/coordinator.py new file mode 100644 index 00000000000000..73aec22250af10 --- /dev/null +++ b/homeassistant/components/subaru/coordinator.py @@ -0,0 +1,97 @@ +"""Data update coordinator for Subaru.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +import time +from typing import Any + +from subarulink import Controller as SubaruAPI, SubaruException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONF_UPDATE_ENABLED, + COORDINATOR_NAME, + FETCH_INTERVAL, + VEHICLE_LAST_UPDATE, + VEHICLE_VIN, +) + +_LOGGER = logging.getLogger(__name__) + + +class SubaruDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching Subaru data.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + *, + controller: SubaruAPI, + vehicle_info: dict[str, dict[str, Any]], + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=COORDINATOR_NAME, + update_interval=timedelta(seconds=FETCH_INTERVAL), + ) + self._controller = controller + self._vehicle_info = vehicle_info + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from Subaru API.""" + try: + return await _refresh_subaru_data( + self.config_entry, self._vehicle_info, self._controller + ) + except SubaruException as err: + raise UpdateFailed(err.message) from err + + +async def _refresh_subaru_data( + config_entry: ConfigEntry, + vehicle_info: dict[str, dict[str, Any]], + controller: SubaruAPI, +) -> dict[str, Any]: + """Refresh local data with data fetched via Subaru API. + + Subaru API calls assume a server side vehicle context + Data fetch/update must be done for each vehicle + """ + data: dict[str, Any] = {} + + for vehicle in vehicle_info.values(): + vin = vehicle[VEHICLE_VIN] + + # Optionally send an "update" remote command to vehicle (throttled with update_interval) + if config_entry.options.get(CONF_UPDATE_ENABLED, False): + await _update_subaru(vehicle, controller) + + # Fetch data from Subaru servers + await controller.fetch(vin, force=True) + + # Update our local data that will go to entity states + if received_data := await controller.get_data(vin): + data[vin] = received_data + + return data + + +async def _update_subaru(vehicle: dict[str, Any], controller: SubaruAPI) -> None: + """Commands remote vehicle update (polls the vehicle to update subaru API cache).""" + cur_time = time.time() + last_update = vehicle[VEHICLE_LAST_UPDATE] + + if cur_time - last_update > controller.get_update_interval(): + await controller.update(vehicle[VEHICLE_VIN], force=True) + vehicle[VEHICLE_LAST_UPDATE] = cur_time diff --git a/homeassistant/components/subaru/device_tracker.py b/homeassistant/components/subaru/device_tracker.py index f8b1b0f5aad867..3c5d6487cb5225 100644 --- a/homeassistant/components/subaru/device_tracker.py +++ b/homeassistant/components/subaru/device_tracker.py @@ -10,10 +10,7 @@ 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, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import get_device_info from .const import ( @@ -24,6 +21,7 @@ VEHICLE_STATUS, VEHICLE_VIN, ) +from .coordinator import SubaruDataUpdateCoordinator async def async_setup_entry( @@ -33,7 +31,7 @@ async def async_setup_entry( ) -> None: """Set up the Subaru device tracker by config_entry.""" entry: dict = hass.data[DOMAIN][config_entry.entry_id] - coordinator: DataUpdateCoordinator = entry[ENTRY_COORDINATOR] + coordinator: SubaruDataUpdateCoordinator = entry[ENTRY_COORDINATOR] vehicle_info: dict = entry[ENTRY_VEHICLES] async_add_entities( SubaruDeviceTracker(vehicle, coordinator) @@ -43,7 +41,7 @@ async def async_setup_entry( class SubaruDeviceTracker( - CoordinatorEntity[DataUpdateCoordinator[dict[str, Any]]], TrackerEntity + CoordinatorEntity[SubaruDataUpdateCoordinator], TrackerEntity ): """Class for Subaru device tracker.""" @@ -51,7 +49,9 @@ class SubaruDeviceTracker( _attr_has_entity_name = True _attr_name = None - def __init__(self, vehicle_info: dict, coordinator: DataUpdateCoordinator) -> None: + def __init__( + self, vehicle_info: dict, coordinator: SubaruDataUpdateCoordinator + ) -> None: """Initialize the device tracker.""" super().__init__(coordinator) self.vin = vehicle_info[VEHICLE_VIN] diff --git a/homeassistant/components/subaru/manifest.json b/homeassistant/components/subaru/manifest.json index 71bc1dd1a9f298..930f497d3fe5b6 100644 --- a/homeassistant/components/subaru/manifest.json +++ b/homeassistant/components/subaru/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@G-Two"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/subaru", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["stdiomask", "subarulink"], "requirements": ["subarulink==0.7.15"] diff --git a/homeassistant/components/subaru/sensor.py b/homeassistant/components/subaru/sensor.py index aa4c4ee16be83b..880e0043fa8a92 100644 --- a/homeassistant/components/subaru/sensor.py +++ b/homeassistant/components/subaru/sensor.py @@ -18,10 +18,7 @@ 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, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.unit_conversion import DistanceConverter, VolumeConverter from homeassistant.util.unit_system import METRIC_SYSTEM @@ -37,6 +34,7 @@ VEHICLE_STATUS, VEHICLE_VIN, ) +from .coordinator import SubaruDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -155,7 +153,7 @@ async def async_setup_entry( def create_vehicle_sensors( - vehicle_info, coordinator: DataUpdateCoordinator + vehicle_info, coordinator: SubaruDataUpdateCoordinator ) -> list[SubaruSensor]: """Instantiate all available sensors for the vehicle.""" sensor_descriptions_to_add = [] @@ -180,9 +178,7 @@ def create_vehicle_sensors( ] -class SubaruSensor( - CoordinatorEntity[DataUpdateCoordinator[dict[str, Any]]], SensorEntity -): +class SubaruSensor(CoordinatorEntity[SubaruDataUpdateCoordinator], SensorEntity): """Class for Subaru sensors.""" _attr_has_entity_name = True @@ -190,7 +186,7 @@ class SubaruSensor( def __init__( self, vehicle_info: dict, - coordinator: DataUpdateCoordinator, + coordinator: SubaruDataUpdateCoordinator, description: SensorEntityDescription, ) -> None: """Initialize the sensor.""" diff --git a/homeassistant/components/subaru/strings.json b/homeassistant/components/subaru/strings.json index e43fc4a67cbcbf..699dca1f05d9f3 100644 --- a/homeassistant/components/subaru/strings.json +++ b/homeassistant/components/subaru/strings.json @@ -42,7 +42,7 @@ "username": "[%key:common::config_flow::data::username%]" }, "description": "Please enter your MySubaru credentials\nNOTE: Initial setup may take up to 30 seconds", - "title": "Subaru Starlink configuration" + "title": "MySubaru Connected Services configuration" } } }, @@ -95,7 +95,7 @@ "update_enabled": "Enable vehicle polling" }, "description": "When enabled, vehicle polling will send a remote command to your vehicle every 2 hours to obtain new sensor data. Without vehicle polling, new sensor data is only received when the vehicle automatically pushes data (normally after engine shutdown).", - "title": "Subaru Starlink options" + "title": "MySubaru Connected Services options" } } }, diff --git a/homeassistant/components/suez_water/manifest.json b/homeassistant/components/suez_water/manifest.json index 5c23240ce9196f..c91d326e0878f6 100644 --- a/homeassistant/components/suez_water/manifest.json +++ b/homeassistant/components/suez_water/manifest.json @@ -5,6 +5,7 @@ "codeowners": ["@ooii", "@jb101010-2"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/suez_water", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pysuez", "regex"], "quality_scale": "bronze", diff --git a/homeassistant/components/sunricher_dali/manifest.json b/homeassistant/components/sunricher_dali/manifest.json index 80524a9bfb117a..d5a76d0d0d8bad 100644 --- a/homeassistant/components/sunricher_dali/manifest.json +++ b/homeassistant/components/sunricher_dali/manifest.json @@ -9,6 +9,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/sunricher_dali", + "integration_type": "hub", "iot_class": "local_push", "quality_scale": "silver", "requirements": ["PySrDaliGateway==0.19.3"] diff --git a/homeassistant/components/supervisord/sensor.py b/homeassistant/components/supervisord/sensor.py index c14eb6fb353d43..555e44e7354b58 100644 --- a/homeassistant/components/supervisord/sensor.py +++ b/homeassistant/components/supervisord/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import Any import xmlrpc.client import voluptuous as vol @@ -76,7 +77,7 @@ def available(self) -> bool: return self._available @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return { ATTR_DESCRIPTION: self._info.get("description"), diff --git a/homeassistant/components/supla/__init__.py b/homeassistant/components/supla/__init__.py index 62f9b4b232da35..0c7a3c354c832f 100644 --- a/homeassistant/components/supla/__init__.py +++ b/homeassistant/components/supla/__init__.py @@ -2,8 +2,6 @@ from __future__ import annotations -import asyncio -from datetime import timedelta import logging from asyncpysupla import SuplaAPI @@ -15,7 +13,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .coordinator import SuplaCoordinator _LOGGER = logging.getLogger(__name__) @@ -23,8 +22,6 @@ CONF_SERVER = "server" CONF_SERVERS = "servers" -SCAN_INTERVAL = timedelta(seconds=10) - SUPLA_FUNCTION_HA_CMP_MAP = { "CONTROLLINGTHEROLLERSHUTTER": Platform.COVER, "CONTROLLINGTHEGATE": Platform.COVER, @@ -98,23 +95,7 @@ async def discover_devices(hass, hass_config): component_configs: dict[Platform, dict[str, dict]] = {} for server_name, server in hass.data[DOMAIN][SUPLA_SERVERS].items(): - - async def _fetch_channels(): - async with asyncio.timeout(SCAN_INTERVAL.total_seconds()): - return { - channel["id"]: channel - for channel in await server.get_channels( # noqa: B023 - include=["iodevice", "state", "connected"] - ) - } - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name=f"{DOMAIN}-{server_name}", - update_method=_fetch_channels, - update_interval=SCAN_INTERVAL, - ) + coordinator = SuplaCoordinator(hass, server, server_name) await coordinator.async_refresh() diff --git a/homeassistant/components/supla/coordinator.py b/homeassistant/components/supla/coordinator.py new file mode 100644 index 00000000000000..0e0a4792b51c3e --- /dev/null +++ b/homeassistant/components/supla/coordinator.py @@ -0,0 +1,45 @@ +"""DataUpdateCoordinator for the Supla integration.""" + +from __future__ import annotations + +import asyncio +from datetime import timedelta +import logging + +from asyncpysupla import SuplaAPI + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=10) + + +class SuplaCoordinator(DataUpdateCoordinator[dict[int, dict]]): + """Class to manage fetching Supla channel data.""" + + def __init__( + self, + hass: HomeAssistant, + server: SuplaAPI, + server_name: str, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"supla-{server_name}", + update_interval=SCAN_INTERVAL, + ) + self._server = server + + async def _async_update_data(self) -> dict[int, dict]: + """Fetch channels from the Supla API.""" + async with asyncio.timeout(SCAN_INTERVAL.total_seconds()): + return { + channel["id"]: channel + for channel in await self._server.get_channels( + include=["iodevice", "state", "connected"] + ) + } diff --git a/homeassistant/components/supla/entity.py b/homeassistant/components/supla/entity.py index 446d67d19d64e9..8f4619b0a42c00 100644 --- a/homeassistant/components/supla/entity.py +++ b/homeassistant/components/supla/entity.py @@ -6,10 +6,12 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .coordinator import SuplaCoordinator + _LOGGER = logging.getLogger(__name__) -class SuplaEntity(CoordinatorEntity): +class SuplaEntity(CoordinatorEntity[SuplaCoordinator]): """Base class of a SUPLA Channel (an equivalent of HA's Entity).""" def __init__(self, config, server, coordinator): diff --git a/homeassistant/components/supla/switch.py b/homeassistant/components/supla/switch.py index 5afcb9f08f6afc..1c8c4593745989 100644 --- a/homeassistant/components/supla/switch.py +++ b/homeassistant/components/supla/switch.py @@ -56,7 +56,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: await self.async_action("TURN_OFF") @property - def is_on(self): + def is_on(self) -> bool: """Return true if switch is on.""" if state := self.channel_data.get("state"): return state["on"] diff --git a/homeassistant/components/surepetcare/manifest.json b/homeassistant/components/surepetcare/manifest.json index bcfd10d2f0208b..4aa24a581e751f 100644 --- a/homeassistant/components/surepetcare/manifest.json +++ b/homeassistant/components/surepetcare/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@benleb", "@danielhiversen"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/surepetcare", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["rich", "surepy"], "requirements": ["surepy==0.9.0"] diff --git a/homeassistant/components/surepetcare/sensor.py b/homeassistant/components/surepetcare/sensor.py index 6146cc97d7584a..6f7dc6a33e9479 100644 --- a/homeassistant/components/surepetcare/sensor.py +++ b/homeassistant/components/surepetcare/sensor.py @@ -6,6 +6,7 @@ from surepy.entities import SurepyEntity from surepy.entities.devices import Felaqua as SurepyFelaqua +from surepy.entities.pet import Pet as SurepyPet from surepy.enums import EntityType from homeassistant.components.sensor import SensorDeviceClass, SensorEntity @@ -41,6 +42,9 @@ async def async_setup_entry( if surepy_entity.type == EntityType.FELAQUA: entities.append(Felaqua(surepy_entity.id, coordinator)) + if surepy_entity.type == EntityType.PET: + entities.append(PetLastSeenFlapDevice(surepy_entity.id, coordinator)) + entities.append(PetLastSeenUser(surepy_entity.id, coordinator)) async_add_entities(entities) @@ -108,3 +112,55 @@ def _update_attr(self, surepy_entity: SurepyEntity) -> None: """Update the state.""" surepy_entity = cast(SurepyFelaqua, surepy_entity) self._attr_native_value = surepy_entity.water_remaining + + +class PetLastSeenFlapDevice(SurePetcareEntity, SensorEntity): + """Sensor for the last flap device id used by the pet. + + Note: Will be unknown if the last status is not from a flap update. + """ + + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_registry_enabled_default = False + + def __init__( + self, surepetcare_id: int, coordinator: SurePetcareDataCoordinator + ) -> None: + """Initialize last seen flap device id sensor.""" + super().__init__(surepetcare_id, coordinator) + + self._attr_name = f"{self._device_name} Last seen flap device id" + self._attr_unique_id = f"{self._device_id}-last_seen_flap_device" + + @callback + def _update_attr(self, surepy_entity: SurepyEntity) -> None: + surepy_entity = cast(SurepyPet, surepy_entity) + position = surepy_entity._data.get("position", {}) # noqa: SLF001 + device_id = position.get("device_id") + self._attr_native_value = str(device_id) if device_id is not None else None + + +class PetLastSeenUser(SurePetcareEntity, SensorEntity): + """Sensor for the last user id that manually changed the pet location. + + Note: Will be unknown if the last status is not from a manual update. + """ + + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_registry_enabled_default = False + + def __init__( + self, surepetcare_id: int, coordinator: SurePetcareDataCoordinator + ) -> None: + """Initialize last seen user id sensor.""" + super().__init__(surepetcare_id, coordinator) + + self._attr_name = f"{self._device_name} Last seen user id" + self._attr_unique_id = f"{self._device_id}-last_seen_user" + + @callback + def _update_attr(self, surepy_entity: SurepyEntity) -> None: + surepy_entity = cast(SurepyPet, surepy_entity) + position = surepy_entity._data.get("position", {}) # noqa: SLF001 + user_id = position.get("user_id") + self._attr_native_value = str(user_id) if user_id is not None else None diff --git a/homeassistant/components/swiss_hydrological_data/sensor.py b/homeassistant/components/swiss_hydrological_data/sensor.py index 897b440a93496a..fdec1df6df2f39 100644 --- a/homeassistant/components/swiss_hydrological_data/sensor.py +++ b/homeassistant/components/swiss_hydrological_data/sensor.py @@ -4,6 +4,7 @@ from datetime import timedelta import logging +from typing import TYPE_CHECKING, Any from swisshydrodata import SwissHydroData import voluptuous as vol @@ -66,8 +67,8 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Swiss hydrological sensor.""" - station = config[CONF_STATION] - monitored_conditions = config[CONF_MONITORED_CONDITIONS] + station: int = config[CONF_STATION] + monitored_conditions: list[str] = config[CONF_MONITORED_CONDITIONS] hydro_data = HydrologicalData(station) hydro_data.update() @@ -92,42 +93,28 @@ class SwissHydrologicalDataSensor(SensorEntity): "Data provided by the Swiss Federal Office for the Environment FOEN" ) - def __init__(self, hydro_data, station, condition): + def __init__( + self, hydro_data: HydrologicalData, station: int, condition: str + ) -> None: """Initialize the Swiss hydrological sensor.""" self.hydro_data = hydro_data + data = hydro_data.data + if TYPE_CHECKING: + # Setup will fail in setup_platform if the data is None. + assert data is not None + self._condition = condition - self._data = self._state = self._unit_of_measurement = None - self._icon = CONDITIONS[condition] + self._data: dict[str, Any] | None = data + self._attr_icon = CONDITIONS[condition] + self._attr_name = f"{data['water-body-name']} {condition}" + self._attr_native_unit_of_measurement = data["parameters"][condition]["unit"] + self._attr_unique_id = f"{station}_{condition}" self._station = station @property - def name(self): - """Return the name of the sensor.""" - return f"{self._data['water-body-name']} {self._condition}" - - @property - def unique_id(self) -> str: - """Return a unique, friendly identifier for this entity.""" - return f"{self._station}_{self._condition}" - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - if self._state is not None: - return self.hydro_data.data["parameters"][self._condition]["unit"] - return None - - @property - def native_value(self): - """Return the state of the sensor.""" - if isinstance(self._state, (int, float)): - return round(self._state, 2) - return None - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the device state attributes.""" - attrs = {} + attrs: dict[str, Any] = {} if not self._data: return attrs @@ -145,32 +132,28 @@ def extra_state_attributes(self): return attrs - @property - def icon(self): - """Icon to use in the frontend.""" - return self._icon - def update(self) -> None: """Get the latest data and update the state.""" self.hydro_data.update() self._data = self.hydro_data.data - if self._data is None: - self._state = None - else: - self._state = self._data["parameters"][self._condition]["value"] + self._attr_native_value = None + if self._data is not None: + state = self._data["parameters"][self._condition]["value"] + if isinstance(state, (int, float)): + self._attr_native_value = round(state, 2) class HydrologicalData: """The Class for handling the data retrieval.""" - def __init__(self, station): + def __init__(self, station: int) -> None: """Initialize the data object.""" self.station = station - self.data = None + self.data: dict[str, Any] | None = None @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): + def update(self) -> None: """Get the latest data.""" shd = SwissHydroData() diff --git a/homeassistant/components/swiss_public_transport/manifest.json b/homeassistant/components/swiss_public_transport/manifest.json index 105093280431f4..cd12f1bc3be9d1 100644 --- a/homeassistant/components/swiss_public_transport/manifest.json +++ b/homeassistant/components/swiss_public_transport/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@fabaff", "@miaucl"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/swiss_public_transport", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["opendata_transport"], "requirements": ["python-opendata-transport==0.5.0"] diff --git a/homeassistant/components/switchbee/manifest.json b/homeassistant/components/switchbee/manifest.json index 2e7b15e0561ccb..1584f7d46db48d 100644 --- a/homeassistant/components/switchbee/manifest.json +++ b/homeassistant/components/switchbee/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@jafar-atili"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/switchbee", + "integration_type": "hub", "iot_class": "local_push", "requirements": ["pyswitchbee==1.8.3"] } diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index d77e9e2df4e2f8..d5946644e26503 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -53,7 +53,11 @@ Platform.SENSOR, ], SupportedModels.HYGROMETER.value: [Platform.SENSOR], - SupportedModels.HYGROMETER_CO2.value: [Platform.SENSOR], + SupportedModels.HYGROMETER_CO2.value: [ + Platform.BUTTON, + Platform.SENSOR, + Platform.SELECT, + ], SupportedModels.CONTACT.value: [Platform.BINARY_SENSOR, Platform.SENSOR], SupportedModels.MOTION.value: [Platform.BINARY_SENSOR, Platform.SENSOR], SupportedModels.PRESENCE_SENSOR.value: [Platform.BINARY_SENSOR, Platform.SENSOR], @@ -123,8 +127,16 @@ Platform.BINARY_SENSOR, Platform.BUTTON, ], - SupportedModels.KEYPAD_VISION.value: [Platform.SENSOR, Platform.BINARY_SENSOR], - SupportedModels.KEYPAD_VISION_PRO.value: [Platform.SENSOR, Platform.BINARY_SENSOR], + SupportedModels.KEYPAD_VISION.value: [ + Platform.SENSOR, + Platform.BINARY_SENSOR, + Platform.EVENT, + ], + SupportedModels.KEYPAD_VISION_PRO.value: [ + Platform.SENSOR, + Platform.BINARY_SENSOR, + Platform.EVENT, + ], } CLASS_BY_DEVICE = { SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight, @@ -164,6 +176,7 @@ SupportedModels.ART_FRAME.value: switchbot.SwitchbotArtFrame, SupportedModels.KEYPAD_VISION.value: switchbot.SwitchbotKeypadVision, SupportedModels.KEYPAD_VISION_PRO.value: switchbot.SwitchbotKeypadVision, + SupportedModels.HYGROMETER_CO2.value: switchbot.SwitchbotMeterProCO2, } diff --git a/homeassistant/components/switchbot/binary_sensor.py b/homeassistant/components/switchbot/binary_sensor.py index f98b356924729a..ef035bbfdf2e0d 100644 --- a/homeassistant/components/switchbot/binary_sensor.py +++ b/homeassistant/components/switchbot/binary_sensor.py @@ -85,10 +85,13 @@ class SwitchbotBinarySensorEntityDescription(BinarySensorEntityDescription): ), "battery_charging": SwitchbotBinarySensorEntityDescription( key="battery_charging", - translation_key="battery_charging", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.BATTERY_CHARGING, ), + "tamper_alarm": SwitchbotBinarySensorEntityDescription( + key="tamper_alarm", + device_class=BinarySensorDeviceClass.TAMPER, + ), } diff --git a/homeassistant/components/switchbot/button.py b/homeassistant/components/switchbot/button.py index a5a32f96f50f64..3d9db9074f2026 100644 --- a/homeassistant/components/switchbot/button.py +++ b/homeassistant/components/switchbot/button.py @@ -5,8 +5,10 @@ import switchbot from homeassistant.components.button import ButtonEntity +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 SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator from .entity import SwitchbotEntity, exception_handler @@ -31,6 +33,9 @@ async def async_setup_entry( ] ) + if isinstance(coordinator.device, switchbot.SwitchbotMeterProCO2): + async_add_entities([SwitchBotMeterProCO2SyncDateTimeButton(coordinator)]) + class SwitchBotArtFrameButtonBase(SwitchbotEntity, ButtonEntity): """Base class for Art Frame buttons.""" @@ -64,3 +69,45 @@ async def async_press(self) -> None: """Handle the button press.""" _LOGGER.debug("Pressing previous image button %s", self._address) await self._device.prev_image() + + +class SwitchBotMeterProCO2SyncDateTimeButton(SwitchbotEntity, ButtonEntity): + """Button to sync date and time on Meter Pro CO2 to the current HA instance datetime.""" + + _device: switchbot.SwitchbotMeterProCO2 + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "sync_datetime" + + def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: + """Initialize the sync time button.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.base_unique_id}_sync_datetime" + + @exception_handler + async def async_press(self) -> None: + """Sync time with Home Assistant.""" + now = dt_util.now() + + # Get UTC offset components + utc_offset = now.utcoffset() + utc_offset_hours, utc_offset_minutes = 0, 0 + if utc_offset is not None: + total_seconds = int(utc_offset.total_seconds()) + utc_offset_hours = total_seconds // 3600 + utc_offset_minutes = abs(total_seconds % 3600) // 60 + + timestamp = int(now.timestamp()) + + _LOGGER.debug( + "Syncing time for %s: timestamp=%s, utc_offset_hours=%s, utc_offset_minutes=%s", + self._address, + timestamp, + utc_offset_hours, + utc_offset_minutes, + ) + + await self._device.set_datetime( + timestamp=timestamp, + utc_offset_hours=utc_offset_hours, + utc_offset_minutes=utc_offset_minutes, + ) diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py index 35e8f8419ed2da..18a6cce507c03d 100644 --- a/homeassistant/components/switchbot/config_flow.py +++ b/homeassistant/components/switchbot/config_flow.py @@ -227,6 +227,9 @@ async def async_step_encrypted_auth( # Clear saved credentials if auth failed self._cloud_username = None self._cloud_password = None + except Exception: + _LOGGER.exception("Unexpected error retrieving encryption key") + errors = {"base": "unknown"} else: return await self.async_step_encrypted_key(key_details) @@ -366,6 +369,9 @@ async def async_step_cloud_login( _LOGGER.debug("Authentication failed: %s", ex, exc_info=True) errors = {"base": "auth_failed"} description_placeholders = {"error_detail": str(ex)} + except Exception: + _LOGGER.exception("Unexpected error during cloud login") + errors = {"base": "unknown"} else: # Save credentials temporarily for the duration of this flow # to avoid re-prompting if encrypted device auth is needed diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index 8617f82d6cff4e..a94c52dba816bf 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -106,13 +106,13 @@ class SupportedModels(StrEnum): SwitchbotModel.ART_FRAME: SupportedModels.ART_FRAME, SwitchbotModel.KEYPAD_VISION: SupportedModels.KEYPAD_VISION, SwitchbotModel.KEYPAD_VISION_PRO: SupportedModels.KEYPAD_VISION_PRO, + SwitchbotModel.METER_PRO_C: SupportedModels.HYGROMETER_CO2, } NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.METER: SupportedModels.HYGROMETER, SwitchbotModel.IO_METER: SupportedModels.HYGROMETER, SwitchbotModel.METER_PRO: SupportedModels.HYGROMETER, - SwitchbotModel.METER_PRO_C: SupportedModels.HYGROMETER_CO2, SwitchbotModel.CONTACT_SENSOR: SupportedModels.CONTACT, SwitchbotModel.MOTION_SENSOR: SupportedModels.MOTION, SwitchbotModel.PRESENCE_SENSOR: SupportedModels.PRESENCE_SENSOR, diff --git a/homeassistant/components/switchbot/event.py b/homeassistant/components/switchbot/event.py new file mode 100644 index 00000000000000..30ccca7ea95cca --- /dev/null +++ b/homeassistant/components/switchbot/event.py @@ -0,0 +1,63 @@ +"""Support for SwitchBot event entities.""" + +from __future__ import annotations + +from homeassistant.components.event import ( + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator +from .entity import SwitchbotEntity + +PARALLEL_UPDATES = 0 + +EVENT_TYPES = { + "doorbell": EventEntityDescription( + key="doorbell", + device_class=EventDeviceClass.DOORBELL, + event_types=["ring"], + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: SwitchbotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the SwitchBot event platform.""" + coordinator = config_entry.runtime_data + async_add_entities( + SwitchbotEventEntity(coordinator, event, description) + for event, description in EVENT_TYPES.items() + if event in coordinator.device.parsed_data + ) + + +class SwitchbotEventEntity(SwitchbotEntity, EventEntity): + """Representation of a SwitchBot event.""" + + def __init__( + self, + coordinator: SwitchbotDataUpdateCoordinator, + event: str, + description: EventEntityDescription, + ) -> None: + """Initialize the SwitchBot event.""" + super().__init__(coordinator) + self._event = event + self.entity_description = description + self._attr_unique_id = f"{coordinator.base_unique_id}-{event}" + self._previous_value = False + + @callback + def _async_update_attrs(self) -> None: + """Update the entity attributes.""" + value = bool(self.parsed_data.get(self._event, False)) + if value and not self._previous_value: + self._trigger_event("ring") + self._previous_value = value diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 8c26c02bf39c5a..90454ca54adb22 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==1.0.0"] + "requirements": ["PySwitchbot==1.1.0"] } diff --git a/homeassistant/components/switchbot/select.py b/homeassistant/components/switchbot/select.py new file mode 100644 index 00000000000000..5322b22f2c34f3 --- /dev/null +++ b/homeassistant/components/switchbot/select.py @@ -0,0 +1,75 @@ +"""Select platform for SwitchBot.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +import switchbot +from switchbot.devices.device import SwitchbotOperationError + +from homeassistant.components.select import SelectEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator +from .entity import SwitchbotEntity, exception_handler + +_LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + +SCAN_INTERVAL = timedelta(days=7) +TIME_FORMAT_12H = "12h" +TIME_FORMAT_24H = "24h" +TIME_FORMAT_OPTIONS = [TIME_FORMAT_12H, TIME_FORMAT_24H] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SwitchbotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SwitchBot select platform.""" + coordinator = entry.runtime_data + + if isinstance(coordinator.device, switchbot.SwitchbotMeterProCO2): + async_add_entities([SwitchBotMeterProCO2TimeFormatSelect(coordinator)], True) + + +class SwitchBotMeterProCO2TimeFormatSelect(SwitchbotEntity, SelectEntity): + """Select entity to set time display format on Meter Pro CO2.""" + + _attr_should_poll = True + _attr_entity_registry_enabled_default = False + _device: switchbot.SwitchbotMeterProCO2 + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "time_format" + _attr_options = TIME_FORMAT_OPTIONS + + def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: + """Initialize the select entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.base_unique_id}_time_format" + + @exception_handler + async def async_select_option(self, option: str) -> None: + """Change the time display format.""" + _LOGGER.debug("Setting time format to %s for %s", option, self._address) + is_12h_mode = option == TIME_FORMAT_12H + await self._device.set_time_display_format(is_12h_mode) + self._attr_current_option = option + self.async_write_ha_state() + + async def async_update(self) -> None: + """Fetch the latest time format from the device.""" + try: + device_time = await self._device.get_datetime() + except SwitchbotOperationError: + _LOGGER.debug( + "Failed to update time format for %s", self._address, exc_info=True + ) + return + self._attr_current_option = ( + TIME_FORMAT_12H if device_time["12h_mode"] else TIME_FORMAT_24H + ) diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index d08a6279f733ca..5d306ed2aaacfd 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -9,7 +9,8 @@ }, "error": { "auth_failed": "Authentication failed: {error_detail}", - "encryption_key_invalid": "Key ID or encryption key is invalid" + "encryption_key_invalid": "Key ID or encryption key is invalid", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "flow_title": "{name} ({address})", "step": { @@ -106,6 +107,9 @@ }, "previous_image": { "name": "Previous image" + }, + "sync_datetime": { + "name": "Sync date and time" } }, "climate": { @@ -265,6 +269,15 @@ } } }, + "select": { + "time_format": { + "name": "Time format", + "state": { + "12h": "12-hour (AM/PM)", + "24h": "24-hour" + } + } + }, "sensor": { "aqi_quality_level": { "name": "Air quality level", diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index a2e35c6ce5750e..aa32576e8a276d 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -190,6 +190,8 @@ async def make_device_data( "Smart Lock Vision", "Smart Lock Vision Pro", "Smart Lock Pro Wifi", + "Lock Vision", + "Lock Vision Pro", ]: coordinator = await coordinator_for_device( hass, entry, api, device, coordinators_by_id @@ -245,13 +247,18 @@ async def make_device_data( if isinstance(device, Device) and device.device_type in [ "Battery Circulator Fan", - "Circulator Fan", + "Standing Fan", ]: coordinator = await coordinator_for_device( hass, entry, api, device, coordinators_by_id ) devices_data.fans.append((device, coordinator)) devices_data.sensors.append((device, coordinator)) + if isinstance(device, Device) and device.device_type == "Circulator Fan": + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id + ) + devices_data.fans.append((device, coordinator)) if isinstance(device, Device) and device.device_type in [ "Curtain", "Curtain3", diff --git a/homeassistant/components/switchbot_cloud/binary_sensor.py b/homeassistant/components/switchbot_cloud/binary_sensor.py index 5713c1e7f0301d..dac916c6caecb2 100644 --- a/homeassistant/components/switchbot_cloud/binary_sensor.py +++ b/homeassistant/components/switchbot_cloud/binary_sensor.py @@ -102,6 +102,14 @@ class SwitchBotCloudBinarySensorEntityDescription(BinarySensorEntityDescription) CALIBRATION_DESCRIPTION, DOOR_OPEN_DESCRIPTION, ), + "Lock Vision": ( + CALIBRATION_DESCRIPTION, + DOOR_OPEN_DESCRIPTION, + ), + "Lock Vision Pro": ( + CALIBRATION_DESCRIPTION, + DOOR_OPEN_DESCRIPTION, + ), "Smart Lock Pro Wifi": ( CALIBRATION_DESCRIPTION, DOOR_OPEN_DESCRIPTION, diff --git a/homeassistant/components/switchbot_cloud/climate.py b/homeassistant/components/switchbot_cloud/climate.py index ce3429b8d48de3..629e34197f4a47 100644 --- a/homeassistant/components/switchbot_cloud/climate.py +++ b/homeassistant/components/switchbot_cloud/climate.py @@ -17,13 +17,11 @@ from homeassistant.components.climate import ( ATTR_FAN_MODE, ATTR_TEMPERATURE, - PRESET_AWAY, PRESET_BOOST, PRESET_COMFORT, PRESET_ECO, PRESET_HOME, PRESET_NONE, - PRESET_SLEEP, ClimateEntity, ClimateEntityFeature, HVACMode, @@ -40,7 +38,11 @@ from homeassistant.helpers.restore_state import RestoreEntity from . import SwitchbotCloudData, SwitchBotCoordinator -from .const import DOMAIN, SMART_RADIATOR_THERMOSTAT_AFTER_COMMAND_REFRESH +from .const import ( + CLIMATE_PRESET_SCHEDULE, + DOMAIN, + SMART_RADIATOR_THERMOSTAT_AFTER_COMMAND_REFRESH, +) from .entity import SwitchBotCloudEntity _LOGGER = getLogger(__name__) @@ -206,6 +208,7 @@ async def async_turn_on(self) -> None: PRESET_BOOST: SmartRadiatorThermostatMode.FAST_HEATING, PRESET_COMFORT: SmartRadiatorThermostatMode.COMFORT, PRESET_HOME: SmartRadiatorThermostatMode.MANUAL, + CLIMATE_PRESET_SCHEDULE: SmartRadiatorThermostatMode.SCHEDULE, } RADIATOR_HA_PRESET_MODE_MAP = { @@ -227,15 +230,10 @@ class SwitchBotCloudSmartRadiatorThermostat(SwitchBotCloudEntity, ClimateEntity) _attr_target_temperature_step = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_preset_modes = [ - PRESET_NONE, - PRESET_ECO, - PRESET_AWAY, - PRESET_BOOST, - PRESET_COMFORT, - PRESET_HOME, - PRESET_SLEEP, - ] + _attr_preset_modes = list(RADIATOR_PRESET_MODE_MAP) + + _attr_translation_key = "smart_radiator_thermostat" + _attr_preset_mode = PRESET_HOME _attr_hvac_modes = [ @@ -300,7 +298,7 @@ def _set_attributes(self) -> None: SmartRadiatorThermostatMode(mode) ] - if self.preset_mode in [PRESET_NONE, PRESET_AWAY]: + if self.preset_mode == PRESET_NONE: self._attr_hvac_mode = HVACMode.OFF else: self._attr_hvac_mode = HVACMode.HEAT diff --git a/homeassistant/components/switchbot_cloud/const.py b/homeassistant/components/switchbot_cloud/const.py index 448c4a44ddb5d1..15e958b4777431 100644 --- a/homeassistant/components/switchbot_cloud/const.py +++ b/homeassistant/components/switchbot_cloud/const.py @@ -17,6 +17,9 @@ VACUUM_FAN_SPEED_STRONG = "strong" VACUUM_FAN_SPEED_MAX = "max" + +CLIMATE_PRESET_SCHEDULE = "schedule" + AFTER_COMMAND_REFRESH = 5 COVER_ENTITY_AFTER_COMMAND_REFRESH = 10 SMART_RADIATOR_THERMOSTAT_AFTER_COMMAND_REFRESH = 30 diff --git a/homeassistant/components/switchbot_cloud/fan.py b/homeassistant/components/switchbot_cloud/fan.py index 9424b5478ace13..45704d49922ad7 100644 --- a/homeassistant/components/switchbot_cloud/fan.py +++ b/homeassistant/components/switchbot_cloud/fan.py @@ -1,4 +1,4 @@ -"""Support for the Switchbot Battery Circulator fan.""" +"""Support for the Switchbot (Battery) Circulator fan.""" import asyncio import logging @@ -43,7 +43,7 @@ async def async_setup_entry( class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity): - """Representation of a SwitchBot Battery Circulator Fan.""" + """Representation of a SwitchBot (Battery) Circulator Fan.""" _attr_name = None @@ -110,10 +110,6 @@ async def async_turn_off(self, **kwargs: Any) -> None: async def async_set_percentage(self, percentage: int) -> None: """Set the speed of the fan, as a percentage.""" - await self.send_api_command( - command=BatteryCirculatorFanCommands.SET_WIND_MODE, - parameters=str(BatteryCirculatorFanMode.DIRECT.value), - ) await self.send_api_command( command=BatteryCirculatorFanCommands.SET_WIND_SPEED, parameters=str(percentage), diff --git a/homeassistant/components/switchbot_cloud/icons.json b/homeassistant/components/switchbot_cloud/icons.json index ca1cbf81dcef47..edd7d4244eacea 100644 --- a/homeassistant/components/switchbot_cloud/icons.json +++ b/homeassistant/components/switchbot_cloud/icons.json @@ -8,6 +8,17 @@ "default": "mdi:chevron-left-box" } }, + "climate": { + "smart_radiator_thermostat": { + "state_attributes": { + "preset_mode": { + "state": { + "schedule": "mdi:clock-outline" + } + } + } + } + }, "fan": { "air_purifier": { "default": "mdi:air-purifier", diff --git a/homeassistant/components/switchbot_cloud/light.py b/homeassistant/components/switchbot_cloud/light.py index 5e6103846de524..d3bf22beebbce6 100644 --- a/homeassistant/components/switchbot_cloud/light.py +++ b/homeassistant/components/switchbot_cloud/light.py @@ -58,6 +58,8 @@ def _get_default_color_mode(self) -> ColorMode: """Return the default color mode.""" if not self.supported_color_modes: return ColorMode.UNKNOWN + if ColorMode.BRIGHTNESS in self.supported_color_modes: + return ColorMode.BRIGHTNESS if ColorMode.RGB in self.supported_color_modes: return ColorMode.RGB if ColorMode.COLOR_TEMP in self.supported_color_modes: @@ -136,6 +138,7 @@ class SwitchBotCloudCandleWarmerLamp(SwitchBotCloudLight): # Brightness adjustment _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + _attr_color_mode = ColorMode.BRIGHTNESS class SwitchBotCloudStripLight(SwitchBotCloudLight): @@ -145,6 +148,7 @@ class SwitchBotCloudStripLight(SwitchBotCloudLight): # RGB color control _attr_supported_color_modes = {ColorMode.RGB} + _attr_color_mode = ColorMode.RGB class SwitchBotCloudRGBICLight(SwitchBotCloudLight): @@ -154,6 +158,7 @@ class SwitchBotCloudRGBICLight(SwitchBotCloudLight): # RGB color control _attr_supported_color_modes = {ColorMode.RGB} + _attr_color_mode = ColorMode.RGB async def _send_rgb_color_command(self, rgb_color: tuple) -> None: """Send an RGB command.""" @@ -174,6 +179,7 @@ class SwitchBotCloudRGBWWLight(SwitchBotCloudLight): _attr_min_color_temp_kelvin = 2700 _attr_supported_color_modes = {ColorMode.RGB, ColorMode.COLOR_TEMP} + _attr_color_mode = ColorMode.RGB async def _send_brightness_command(self, brightness: int) -> None: """Send a brightness command.""" @@ -200,6 +206,7 @@ class SwitchBotCloudCeilingLight(SwitchBotCloudLight): _attr_min_color_temp_kelvin = 2700 _attr_supported_color_modes = {ColorMode.COLOR_TEMP} + _attr_color_mode = ColorMode.COLOR_TEMP async def _send_brightness_command(self, brightness: int) -> None: """Send a brightness command.""" diff --git a/homeassistant/components/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json index 737ddeeef895d8..5af8a9a283c97e 100644 --- a/homeassistant/components/switchbot_cloud/manifest.json +++ b/homeassistant/components/switchbot_cloud/manifest.json @@ -13,5 +13,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["switchbot_api"], - "requirements": ["switchbot-api==2.10.0"] + "requirements": ["switchbot-api==2.11.0"] } diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index 22b3b2b2390656..1b74756bbae2a3 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -169,6 +169,7 @@ class SwitchbotCloudSensorEntityDescription(SensorEntityDescription): SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { "Bot": (BATTERY_DESCRIPTION,), "Battery Circulator Fan": (BATTERY_DESCRIPTION,), + "Standing Fan": (BATTERY_DESCRIPTION,), "Meter": ( TEMPERATURE_DESCRIPTION, HUMIDITY_DESCRIPTION, @@ -227,6 +228,8 @@ class SwitchbotCloudSensorEntityDescription(SensorEntityDescription): "Smart Lock Ultra": (BATTERY_DESCRIPTION,), "Smart Lock Vision": (BATTERY_DESCRIPTION,), "Smart Lock Vision Pro": (BATTERY_DESCRIPTION,), + "Lock Vision": (BATTERY_DESCRIPTION,), + "Lock Vision Pro": (BATTERY_DESCRIPTION,), "Smart Lock Pro Wifi": (BATTERY_DESCRIPTION,), "Relay Switch 2PM": ( RELAY_SWITCH_2PM_POWER_DESCRIPTION, diff --git a/homeassistant/components/switchbot_cloud/strings.json b/homeassistant/components/switchbot_cloud/strings.json index d37a92c6448feb..6883efff030620 100644 --- a/homeassistant/components/switchbot_cloud/strings.json +++ b/homeassistant/components/switchbot_cloud/strings.json @@ -26,6 +26,17 @@ "name": "Previous" } }, + "climate": { + "smart_radiator_thermostat": { + "state_attributes": { + "preset_mode": { + "state": { + "schedule": "Schedule" + } + } + } + } + }, "fan": { "air_purifier": { "state_attributes": { diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json index 2a90f7bc4054ef..9867f009557fbf 100644 --- a/homeassistant/components/switcher_kis/manifest.json +++ b/homeassistant/components/switcher_kis/manifest.json @@ -4,9 +4,10 @@ "codeowners": ["@thecode", "@YogevBokobza"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/switcher_kis", + "integration_type": "hub", "iot_class": "local_push", "loggers": ["aioswitcher"], "quality_scale": "silver", - "requirements": ["aioswitcher==6.1.0"], + "requirements": ["aioswitcher==6.1.1"], "single_config_entry": true } diff --git a/homeassistant/components/syncthing/manifest.json b/homeassistant/components/syncthing/manifest.json index 40d93dce4c7ed5..39d983f0580ceb 100644 --- a/homeassistant/components/syncthing/manifest.json +++ b/homeassistant/components/syncthing/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@zhulik"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/syncthing", + "integration_type": "service", "iot_class": "local_polling", "loggers": ["aiosyncthing"], "requirements": ["aiosyncthing==0.7.1"] diff --git a/homeassistant/components/syncthru/manifest.json b/homeassistant/components/syncthru/manifest.json index a33cefd2c703d6..ec6ecce7acea75 100644 --- a/homeassistant/components/syncthru/manifest.json +++ b/homeassistant/components/syncthru/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@nielstron"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/syncthru", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["pysyncthru"], "requirements": ["PySyncThru==0.8.0", "url-normalize==2.2.1"], diff --git a/homeassistant/components/synology_dsm/backup.py b/homeassistant/components/synology_dsm/backup.py index b3279db1cacf11..3933a3f2fc295c 100644 --- a/homeassistant/components/synology_dsm/backup.py +++ b/homeassistant/components/synology_dsm/backup.py @@ -15,6 +15,7 @@ BackupAgent, BackupAgentError, BackupNotFound, + OnProgressCallback, suggested_filename, ) from homeassistant.core import HomeAssistant, callback @@ -155,6 +156,7 @@ async def async_upload_backup( *, open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], backup: AgentBackup, + on_progress: OnProgressCallback, **kwargs: Any, ) -> None: """Upload a backup. diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json index 5243083e6dc825..4b4289c3cb9123 100644 --- a/homeassistant/components/system_bridge/manifest.json +++ b/homeassistant/components/system_bridge/manifest.json @@ -9,6 +9,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["systembridgeconnector"], - "requirements": ["systembridgeconnector==5.3.1"], + "requirements": ["systembridgeconnector==5.4.3"], "zeroconf": ["_system-bridge._tcp.local."] } diff --git a/homeassistant/components/systemmonitor/coordinator.py b/homeassistant/components/systemmonitor/coordinator.py index 052329ae5ff565..225940e0d44721 100644 --- a/homeassistant/components/systemmonitor/coordinator.py +++ b/homeassistant/components/systemmonitor/coordinator.py @@ -19,6 +19,7 @@ from homeassistant.util import dt as dt_util from .const import CONF_PROCESS, PROCESS_ERRORS +from .util import get_all_pressure_info if TYPE_CHECKING: from . import SystemMonitorConfigEntry @@ -40,6 +41,7 @@ class SensorData: io_counters: dict[str, snetio] load: tuple[float, float, float] memory: VirtualMemory + pressure: dict[str, Any] process_fds: dict[str, int] processes: list[Process] swap: sswap @@ -73,6 +75,7 @@ def as_dict(self) -> dict[str, Any]: "io_counters": io_counters, "load": str(self.load), "memory": str(self.memory), + "pressure": self.pressure, "process_fds": self.process_fds, "processes": str(self.processes), "swap": str(self.swap), @@ -141,6 +144,7 @@ def set_subscribers_tuples( ("io_counters", ""): set(), ("load", ""): set(), ("memory", ""): set(), + ("pressure", ""): set(), ("processes", ""): set(), ("swap", ""): set(), ("temperatures", ""): set(), @@ -173,6 +177,7 @@ async def _async_update_data(self) -> SensorData: io_counters=_data["io_counters"], load=load, memory=_data["memory"], + pressure=_data["pressure"], process_fds=_data["process_fds"], processes=_data["processes"], swap=_data["swap"], @@ -289,6 +294,11 @@ def update_data(self) -> dict[str, Any]: except AttributeError: _LOGGER.debug("OS does not provide battery sensors") + pressure: dict[str, Any] = {} + if self.update_subscribers[("pressure", "")] or self._initial_update: + pressure = get_all_pressure_info() + _LOGGER.debug("pressure: %s", pressure) + return { "addresses": addresses, "battery": battery, @@ -297,6 +307,7 @@ def update_data(self) -> dict[str, Any]: "fan_speed": fan_speed, "io_counters": io_counters, "memory": memory, + "pressure": pressure, "process_fds": process_fds, "processes": selected_processes, "swap": swap, diff --git a/homeassistant/components/systemmonitor/icons.json b/homeassistant/components/systemmonitor/icons.json index 7e8807917379a0..509316531a8ec6 100644 --- a/homeassistant/components/systemmonitor/icons.json +++ b/homeassistant/components/systemmonitor/icons.json @@ -4,6 +4,18 @@ "battery_empty": { "default": "mdi:battery-clock" }, + "cpu_pressure_some_avg10": { + "default": "mdi:gauge" + }, + "cpu_pressure_some_avg300": { + "default": "mdi:gauge" + }, + "cpu_pressure_some_avg60": { + "default": "mdi:gauge" + }, + "cpu_pressure_some_total": { + "default": "mdi:timer-outline" + }, "disk_free": { "default": "mdi:harddisk" }, @@ -16,6 +28,30 @@ "fan_speed": { "default": "mdi:fan" }, + "io_pressure_full_avg10": { + "default": "mdi:gauge" + }, + "io_pressure_full_avg300": { + "default": "mdi:gauge" + }, + "io_pressure_full_avg60": { + "default": "mdi:gauge" + }, + "io_pressure_full_total": { + "default": "mdi:timer-outline" + }, + "io_pressure_some_avg10": { + "default": "mdi:gauge" + }, + "io_pressure_some_avg300": { + "default": "mdi:gauge" + }, + "io_pressure_some_avg60": { + "default": "mdi:gauge" + }, + "io_pressure_some_total": { + "default": "mdi:timer-outline" + }, "ipv4_address": { "default": "mdi:ip-network" }, @@ -25,6 +61,30 @@ "memory_free": { "default": "mdi:memory" }, + "memory_pressure_full_avg10": { + "default": "mdi:gauge" + }, + "memory_pressure_full_avg300": { + "default": "mdi:gauge" + }, + "memory_pressure_full_avg60": { + "default": "mdi:gauge" + }, + "memory_pressure_full_total": { + "default": "mdi:timer-outline" + }, + "memory_pressure_some_avg10": { + "default": "mdi:gauge" + }, + "memory_pressure_some_avg300": { + "default": "mdi:gauge" + }, + "memory_pressure_some_avg60": { + "default": "mdi:gauge" + }, + "memory_pressure_some_total": { + "default": "mdi:timer-outline" + }, "memory_use": { "default": "mdi:memory" }, diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index 33b8e2093cf6d3..fe57ada5318a35 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -30,6 +30,7 @@ UnitOfDataRate, UnitOfInformation, UnitOfTemperature, + UnitOfTime, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er @@ -68,7 +69,11 @@ "memory_", "processor_use", "swap_", + "memory_pressure_", + "io_pressure_", + "cpu_pressure_", ) + SENSORS_WITH_ARG = { "disk_": "disk_arguments", "fan_speed": "fan_speed_arguments", @@ -469,6 +474,224 @@ class SysMonitorSensorEntityDescription(SensorEntityDescription): value_fn=get_throughput, add_to_update=lambda entity: ("io_counters", ""), ), + "memory_pressure_some_avg10": SysMonitorSensorEntityDescription( + key="memory_pressure_some_avg10", + translation_key="memory_pressure_some_avg10", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("memory", {}) + .get("some", {}) + .get("avg10") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "memory_pressure_some_avg60": SysMonitorSensorEntityDescription( + key="memory_pressure_some_avg60", + translation_key="memory_pressure_some_avg60", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("memory", {}) + .get("some", {}) + .get("avg60") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "memory_pressure_some_avg300": SysMonitorSensorEntityDescription( + key="memory_pressure_some_avg300", + translation_key="memory_pressure_some_avg300", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("memory", {}) + .get("some", {}) + .get("avg300") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "memory_pressure_some_total": SysMonitorSensorEntityDescription( + key="memory_pressure_some_total", + translation_key="memory_pressure_some_total", + native_unit_of_measurement=UnitOfTime.MICROSECONDS, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("memory", {}) + .get("some", {}) + .get("total") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "memory_pressure_full_avg10": SysMonitorSensorEntityDescription( + key="memory_pressure_full_avg10", + translation_key="memory_pressure_full_avg10", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("memory", {}) + .get("full", {}) + .get("avg10") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "memory_pressure_full_avg60": SysMonitorSensorEntityDescription( + key="memory_pressure_full_avg60", + translation_key="memory_pressure_full_avg60", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("memory", {}) + .get("full", {}) + .get("avg60") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "memory_pressure_full_avg300": SysMonitorSensorEntityDescription( + key="memory_pressure_full_avg300", + translation_key="memory_pressure_full_avg300", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("memory", {}) + .get("full", {}) + .get("avg300") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "memory_pressure_full_total": SysMonitorSensorEntityDescription( + key="memory_pressure_full_total", + translation_key="memory_pressure_full_total", + native_unit_of_measurement=UnitOfTime.MICROSECONDS, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("memory", {}) + .get("full", {}) + .get("total") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "io_pressure_some_avg10": SysMonitorSensorEntityDescription( + key="io_pressure_some_avg10", + translation_key="io_pressure_some_avg10", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("io", {}).get("some", {}).get("avg10") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "io_pressure_some_avg60": SysMonitorSensorEntityDescription( + key="io_pressure_some_avg60", + translation_key="io_pressure_some_avg60", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("io", {}).get("some", {}).get("avg60") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "io_pressure_some_avg300": SysMonitorSensorEntityDescription( + key="io_pressure_some_avg300", + translation_key="io_pressure_some_avg300", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("io", {}).get("some", {}).get("avg300") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "io_pressure_some_total": SysMonitorSensorEntityDescription( + key="io_pressure_some_total", + translation_key="io_pressure_some_total", + native_unit_of_measurement=UnitOfTime.MICROSECONDS, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("io", {}).get("some", {}).get("total") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "io_pressure_full_avg10": SysMonitorSensorEntityDescription( + key="io_pressure_full_avg10", + translation_key="io_pressure_full_avg10", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("io", {}).get("full", {}).get("avg10") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "io_pressure_full_avg60": SysMonitorSensorEntityDescription( + key="io_pressure_full_avg60", + translation_key="io_pressure_full_avg60", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("io", {}).get("full", {}).get("avg60") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "io_pressure_full_avg300": SysMonitorSensorEntityDescription( + key="io_pressure_full_avg300", + translation_key="io_pressure_full_avg300", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("io", {}).get("full", {}).get("avg300") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "io_pressure_full_total": SysMonitorSensorEntityDescription( + key="io_pressure_full_total", + translation_key="io_pressure_full_total", + native_unit_of_measurement=UnitOfTime.MICROSECONDS, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("io", {}).get("full", {}).get("total") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "cpu_pressure_some_avg10": SysMonitorSensorEntityDescription( + key="cpu_pressure_some_avg10", + translation_key="cpu_pressure_some_avg10", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("cpu", {}).get("some", {}).get("avg10") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "cpu_pressure_some_avg60": SysMonitorSensorEntityDescription( + key="cpu_pressure_some_avg60", + translation_key="cpu_pressure_some_avg60", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("cpu", {}).get("some", {}).get("avg60") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "cpu_pressure_some_avg300": SysMonitorSensorEntityDescription( + key="cpu_pressure_some_avg300", + translation_key="cpu_pressure_some_avg300", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("cpu", {}) + .get("some", {}) + .get("avg300") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "cpu_pressure_some_total": SysMonitorSensorEntityDescription( + key="cpu_pressure_some_total", + translation_key="cpu_pressure_some_total", + native_unit_of_measurement=UnitOfTime.MICROSECONDS, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("cpu", {}).get("some", {}).get("total") + ), + add_to_update=lambda entity: ("pressure", ""), + ), } diff --git a/homeassistant/components/systemmonitor/strings.json b/homeassistant/components/systemmonitor/strings.json index 38d0b63dc51066..6041dc02c08388 100644 --- a/homeassistant/components/systemmonitor/strings.json +++ b/homeassistant/components/systemmonitor/strings.json @@ -19,6 +19,18 @@ "battery_empty": { "name": "Battery empty" }, + "cpu_pressure_some_avg10": { + "name": "CPU pressure some 10s average" + }, + "cpu_pressure_some_avg300": { + "name": "CPU pressure some 300s average" + }, + "cpu_pressure_some_avg60": { + "name": "CPU pressure some 60s average" + }, + "cpu_pressure_some_total": { + "name": "CPU pressure some total" + }, "disk_free": { "name": "Disk free {mount_point}" }, @@ -31,6 +43,30 @@ "fan_speed": { "name": "{fan_name} fan speed" }, + "io_pressure_full_avg10": { + "name": "IO pressure full 10s average" + }, + "io_pressure_full_avg300": { + "name": "IO pressure full 300s average" + }, + "io_pressure_full_avg60": { + "name": "IO pressure full 60s average" + }, + "io_pressure_full_total": { + "name": "IO pressure full total" + }, + "io_pressure_some_avg10": { + "name": "IO pressure some 10s average" + }, + "io_pressure_some_avg300": { + "name": "IO pressure some 300s average" + }, + "io_pressure_some_avg60": { + "name": "IO pressure some 60s average" + }, + "io_pressure_some_total": { + "name": "IO pressure some total" + }, "ipv4_address": { "name": "IPv4 address {ip_address}" }, @@ -52,6 +88,30 @@ "memory_free": { "name": "Memory free" }, + "memory_pressure_full_avg10": { + "name": "Memory pressure full 10s average" + }, + "memory_pressure_full_avg300": { + "name": "Memory pressure full 300s average" + }, + "memory_pressure_full_avg60": { + "name": "Memory pressure full 60s average" + }, + "memory_pressure_full_total": { + "name": "Memory pressure full total" + }, + "memory_pressure_some_avg10": { + "name": "Memory pressure some 10s average" + }, + "memory_pressure_some_avg300": { + "name": "Memory pressure some 300s average" + }, + "memory_pressure_some_avg60": { + "name": "Memory pressure some 60s average" + }, + "memory_pressure_some_total": { + "name": "Memory pressure some total" + }, "memory_use": { "name": "Memory use" }, diff --git a/homeassistant/components/systemmonitor/util.py b/homeassistant/components/systemmonitor/util.py index 1118445dab122f..07790479c78672 100644 --- a/homeassistant/components/systemmonitor/util.py +++ b/homeassistant/components/systemmonitor/util.py @@ -2,6 +2,8 @@ import logging import os +import re +from typing import Any from psutil._common import sfan, shwtemp import psutil_home_assistant as ha_psutil @@ -105,3 +107,65 @@ def read_fan_speed(fans: dict[str, list[sfan]]) -> dict[str, int]: sensor_fans[_label] = round(entry.current, 0) return sensor_fans + + +def parse_pressure_file(file_path: str) -> dict[str, dict[str, float | int]] | None: + """Parses a single /proc/pressure file (cpu, memory, or io). + + Args: + file_path (str): The full path to the pressure file. + + Returns: + dict: A dictionary containing the parsed pressure stall information, + or None if the file cannot be read or parsed. + """ + try: + with open(file_path, encoding="utf-8") as f: + content = f.read() + except OSError: + return None + + data: dict[str, dict[str, float | int]] = {} + # The regex looks for 'some' and 'full' lines and captures the values. + # It accounts for floating point numbers and integer values. + # Example line: "some avg10=0.00 avg60=0.00 avg300=0.00 total=0" + pattern = re.compile(r"(some|full)\s+(.*)") + lines = content.strip().split("\n") + + for line in lines: + match = pattern.match(line) + if match: + line_type, values_str = match.groups() + values: dict[str, float | int] = {} + for item in values_str.split(): + try: + key, value = item.split("=") + # Convert values to float, except for 'total' which is an integer + if key == "total": + values[key] = int(value) + else: + values[key] = float(value) + except ValueError: + continue + data[line_type] = values + + return data + + +def get_all_pressure_info() -> dict[str, Any]: + """Parses all available pressure information from /proc/pressure/. + + Returns: + dict: A dictionary containing cpu, memory, and io pressure info. + Returns an empty dictionary if no pressure files are found. + """ + pressure_info: dict[str, Any] = {} + resources = ["cpu", "memory", "io"] + + for resource in resources: + file_path = f"/proc/pressure/{resource}" + parsed_data = parse_pressure_file(file_path) + if parsed_data: + pressure_info[resource] = parsed_data + + return pressure_info diff --git a/homeassistant/components/systemnexa2/__init__.py b/homeassistant/components/systemnexa2/__init__.py new file mode 100644 index 00000000000000..e1df65d7180a30 --- /dev/null +++ b/homeassistant/components/systemnexa2/__init__.py @@ -0,0 +1,38 @@ +"""The System Nexa 2 integration.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN, MANUFACTURER, PLATFORMS +from .coordinator import SystemNexa2ConfigEntry, SystemNexa2DataUpdateCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: SystemNexa2ConfigEntry) -> bool: + """Set up from a config entry.""" + coordinator = SystemNexa2DataUpdateCoordinator(hass, config_entry=entry) + await coordinator.async_setup() + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, coordinator.data.unique_id)}, + manufacturer=MANUFACTURER, + name=coordinator.data.info_data.name, + model=coordinator.data.info_data.model, + sw_version=coordinator.data.info_data.sw_version, + hw_version=str(coordinator.data.info_data.hw_version), + ) + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: SystemNexa2ConfigEntry +) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + await entry.runtime_data.device.disconnect() + return unload_ok diff --git a/homeassistant/components/systemnexa2/config_flow.py b/homeassistant/components/systemnexa2/config_flow.py new file mode 100644 index 00000000000000..963b523dc4342c --- /dev/null +++ b/homeassistant/components/systemnexa2/config_flow.py @@ -0,0 +1,218 @@ +"""Config flow for the SystemNexa2 integration.""" + +from dataclasses import dataclass +import logging +import socket +from typing import Any + +import aiohttp +from sn2.device import Device +import voluptuous as vol + +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + SOURCE_USER, + ConfigFlow, + ConfigFlowResult, +) +from homeassistant.const import ( + ATTR_MODEL, + ATTR_SW_VERSION, + CONF_DEVICE_ID, + CONF_HOST, + CONF_MODEL, + CONF_NAME, +) +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo +from homeassistant.util.network import is_ip_address + +from . import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + } +) + + +@dataclass(kw_only=True) +class _DiscoveryInfo: + name: str + host: str + model: str + device_id: str + device_version: str + + +class SystemNexa2ConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for the devices.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovered_device: _DiscoveryInfo + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle user-initiated configuration and reconfiguration.""" + errors: dict[str, str] = {} + + if user_input is not None: + host = user_input[CONF_HOST] + if not await self._async_is_valid_host(host): + errors["base"] = "invalid_host" + else: + try: + temp_dev = await Device.initiate_device( + host=host, + session=async_get_clientsession(self.hass), + ) + info = await temp_dev.get_info() + except TimeoutError, aiohttp.ClientError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + device_id = info.information.unique_id + device_model = info.information.model + device_version = info.information.sw_version + supported, error = Device.is_device_supported( + model=device_model, + device_version=device_version, + ) + if device_id is None or device_version is None or not supported: + _LOGGER.error("Unsupported model: %s", error) + return self.async_abort( + reason="unsupported_model", + description_placeholders={ + ATTR_MODEL: str(device_model), + ATTR_SW_VERSION: str(device_version), + }, + ) + + await self.async_set_unique_id(info.information.unique_id) + + if self.source == SOURCE_USER: + self._abort_if_unique_id_configured() + 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={CONF_HOST: host}, + ) + self._discovered_device = _DiscoveryInfo( + name=info.information.name, + host=host, + device_id=device_id, + model=device_model, + device_version=device_version, + ) + return await self._async_create_device_entry() + + if self.source == SOURCE_RECONFIGURE: + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + _SCHEMA, + user_input or self._get_reconfigure_entry().data, + ), + errors=errors, + ) + return self.async_show_form( + step_id="user", + data_schema=_SCHEMA, + errors=errors, + ) + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + device_id = discovery_info.properties.get("id") + device_model = discovery_info.properties.get("model") + device_version = discovery_info.properties.get("version") + supported, error = Device.is_device_supported( + model=device_model, + device_version=device_version, + ) + if ( + device_id is None + or device_model is None + or device_version is None + or not supported + ): + _LOGGER.error("Unsupported model: %s", error) + return self.async_abort(reason="unsupported_model") + + self._discovered_device = _DiscoveryInfo( + name=discovery_info.name.split(".")[0], + host=discovery_info.host, + device_id=device_id, + model=device_model, + device_version=device_version, + ) + await self._async_set_unique_id() + + return await self.async_step_discovery_confirm() + + async def _async_set_unique_id(self) -> None: + await self.async_set_unique_id(self._discovered_device.device_id) + self._abort_if_unique_id_configured( + updates={CONF_HOST: self._discovered_device.host} + ) + + self.context["title_placeholders"] = { + "name": self._discovered_device.name, + "model": self._discovered_device.model or "Unknown model", + } + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + if user_input is not None and self._discovered_device is not None: + return await self._async_create_device_entry() + self._set_confirm_only() + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={"name": self._discovered_device.name}, + ) + + async def _async_create_device_entry(self) -> ConfigFlowResult: + device_name = self._discovered_device.name + device_model = self._discovered_device.model + return self.async_create_entry( + title=f"{device_name} ({device_model})", + data={ + CONF_HOST: self._discovered_device.host, + CONF_NAME: self._discovered_device.name, + CONF_MODEL: self._discovered_device.model, + CONF_DEVICE_ID: self._discovered_device.device_id, + }, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + return await self.async_step_user(user_input) + + async def _async_is_valid_host(self, ip_or_hostname: str) -> bool: + + if not ip_or_hostname: + return False + if is_ip_address(ip_or_hostname): + return True + try: + await self.hass.async_add_executor_job(socket.gethostbyname, ip_or_hostname) + + except socket.gaierror: + return False + return True diff --git a/homeassistant/components/systemnexa2/const.py b/homeassistant/components/systemnexa2/const.py new file mode 100644 index 00000000000000..ed63a607aaadb5 --- /dev/null +++ b/homeassistant/components/systemnexa2/const.py @@ -0,0 +1,9 @@ +"""Constants for the systemnexa2 integration.""" + +from typing import Final + +from homeassistant.const import Platform + +DOMAIN = "systemnexa2" +MANUFACTURER = "NEXA" +PLATFORMS: Final = [Platform.LIGHT, Platform.SENSOR, Platform.SWITCH] diff --git a/homeassistant/components/systemnexa2/coordinator.py b/homeassistant/components/systemnexa2/coordinator.py new file mode 100644 index 00000000000000..d52702148f6d44 --- /dev/null +++ b/homeassistant/components/systemnexa2/coordinator.py @@ -0,0 +1,171 @@ +"""Data coordinator for System Nexa 2 integration.""" + +from collections.abc import Awaitable +import logging + +import aiohttp +from sn2 import ( + ConnectionStatus, + Device, + DeviceInitializationError, + InformationData, + NotConnectedError, + OnOffSetting, + SettingsUpdate, + StateChange, +) +from sn2.device import Setting, UpdateEvent + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type SystemNexa2ConfigEntry = ConfigEntry[SystemNexa2DataUpdateCoordinator] + + +class InsufficientDeviceInformation(Exception): + """Exception raised when device does not provide sufficient information.""" + + +class SystemNexa2Data: + """Data container for System Nexa 2 device information.""" + + __slots__ = ( + "info_data", + "on_off_settings", + "state", + "unique_id", + ) + + info_data: InformationData + unique_id: str + on_off_settings: dict[str, OnOffSetting] + state: float | None + + def __init__(self) -> None: + """Initialize the data container.""" + self.state = None + self.on_off_settings = {} + + def update_settings(self, settings: list[Setting]) -> None: + """Update the on/off settings from a list of settings.""" + self.on_off_settings = { + setting.name: setting + for setting in settings + if isinstance(setting, OnOffSetting) + } + + +class SystemNexa2DataUpdateCoordinator(DataUpdateCoordinator[SystemNexa2Data]): + """Data update coordinator for System Nexa 2 devices.""" + + config_entry: SystemNexa2ConfigEntry + info_data: InformationData + device: Device + + def __init__( + self, + hass: HomeAssistant, + config_entry: SystemNexa2ConfigEntry, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + config_entry=config_entry, + update_interval=None, + update_method=None, + always_update=False, + ) + self._state_received_once = False + self.data = SystemNexa2Data() + + async def async_setup(self) -> None: + """Set up the coordinator and initialize the device connection.""" + try: + self.device = await Device.initiate_device( + host=self.config_entry.data[CONF_HOST], + on_update=self._async_handle_update, + session=async_get_clientsession(self.hass), + ) + + except DeviceInitializationError as e: + _LOGGER.error( + "Failed to initialize device with IP/Hostname %s, please verify that the device is powered on and reachable on port 3000", + self.config_entry.data[CONF_HOST], + ) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_initiate_connection", + translation_placeholders={CONF_HOST: self.config_entry.data[CONF_HOST]}, + ) from e + + self.data.unique_id = self.device.info_data.unique_id + self.data.info_data = self.device.info_data + self.data.update_settings(self.device.settings) + await self.device.connect() + + async def _async_handle_update(self, event: UpdateEvent) -> None: + data = self.data or SystemNexa2Data() + _is_connected = True + match event: + case ConnectionStatus(connected): + _is_connected = connected + case StateChange(state): + data.state = state + self._state_received_once = True + case SettingsUpdate(settings): + data.update_settings(settings) + + if not _is_connected: + self.async_set_update_error(ConnectionError("No connection to device")) + elif ( + data.on_off_settings is not None + and self._state_received_once + and data.state is not None + ): + self.async_set_updated_data(data) + + async def _async_sn2_call_with_error_handling(self, coro: Awaitable[None]) -> None: + """Execute a coroutine with error handling.""" + try: + await coro + except (TimeoutError, NotConnectedError, aiohttp.ClientError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_communication_error", + ) from err + + async def async_turn_on(self) -> None: + """Turn on the device.""" + await self._async_sn2_call_with_error_handling(self.device.turn_on()) + + async def async_turn_off(self) -> None: + """Turn off the device.""" + await self._async_sn2_call_with_error_handling(self.device.turn_off()) + + async def async_toggle(self) -> None: + """Toggle the device.""" + await self._async_sn2_call_with_error_handling(self.device.toggle()) + + async def async_set_brightness(self, value: float) -> None: + """Set the brightness of the device (0.0 to 1.0).""" + await self._async_sn2_call_with_error_handling( + self.device.set_brightness(value) + ) + + async def async_setting_enable(self, setting: OnOffSetting) -> None: + """Enable a device setting.""" + await self._async_sn2_call_with_error_handling(setting.enable(self.device)) + + async def async_setting_disable(self, setting: OnOffSetting) -> None: + """Disable a device setting.""" + await self._async_sn2_call_with_error_handling(setting.disable(self.device)) diff --git a/homeassistant/components/systemnexa2/diagnostics.py b/homeassistant/components/systemnexa2/diagnostics.py new file mode 100644 index 00000000000000..10c1e0d7836a81 --- /dev/null +++ b/homeassistant/components/systemnexa2/diagnostics.py @@ -0,0 +1,40 @@ +"""Diagnostics support for System Nexa 2.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_DEVICE_ID, CONF_HOST +from homeassistant.core import HomeAssistant + +from .coordinator import SystemNexa2ConfigEntry + +TO_REDACT = { + CONF_HOST, + CONF_DEVICE_ID, + "unique_id", + "wifi_ssid", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: SystemNexa2ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + + return { + "config_entry": async_redact_data(dict(entry.data), TO_REDACT), + "device_info": async_redact_data(asdict(coordinator.data.info_data), TO_REDACT), + "coordinator_available": coordinator.last_update_success, + "state": coordinator.data.state, + "settings": { + name: { + "name": setting.name, + "enabled": setting.is_enabled(), + } + for name, setting in coordinator.data.on_off_settings.items() + }, + } diff --git a/homeassistant/components/systemnexa2/entity.py b/homeassistant/components/systemnexa2/entity.py new file mode 100644 index 00000000000000..b2e57dae44f4a3 --- /dev/null +++ b/homeassistant/components/systemnexa2/entity.py @@ -0,0 +1,30 @@ +"""Base entity for SystemNexa2 integration.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import SystemNexa2DataUpdateCoordinator + + +class SystemNexa2Entity(CoordinatorEntity[SystemNexa2DataUpdateCoordinator]): + """Base entity class for SystemNexa2 devices.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: SystemNexa2DataUpdateCoordinator, + key: str, + ) -> None: + """Initialize the SystemNexa2 entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.data.unique_id}-{key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.data.unique_id)}, + manufacturer=MANUFACTURER, + name=coordinator.data.info_data.name, + model=coordinator.data.info_data.model, + sw_version=coordinator.data.info_data.sw_version, + hw_version=str(coordinator.data.info_data.hw_version), + ) diff --git a/homeassistant/components/systemnexa2/light.py b/homeassistant/components/systemnexa2/light.py new file mode 100644 index 00000000000000..7a28988db70515 --- /dev/null +++ b/homeassistant/components/systemnexa2/light.py @@ -0,0 +1,74 @@ +"""Light entity for the SystemNexa2 integration.""" + +from typing import Any + +from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SystemNexa2ConfigEntry, SystemNexa2DataUpdateCoordinator +from .entity import SystemNexa2Entity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SystemNexa2ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up lights based on a config entry.""" + coordinator = entry.runtime_data + + # Only add light entity for dimmable devices + if coordinator.data.info_data.dimmable: + async_add_entities([SystemNexa2Light(coordinator)]) + + +class SystemNexa2Light(SystemNexa2Entity, LightEntity): + """Representation of a dimmable SystemNexa2 light.""" + + _attr_translation_key = "light" + _attr_color_mode = ColorMode.BRIGHTNESS + _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + + def __init__( + self, + coordinator: SystemNexa2DataUpdateCoordinator, + ) -> None: + """Initialize the light.""" + super().__init__( + coordinator=coordinator, + key="light", + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the light.""" + # Check if we're setting brightness + if ATTR_BRIGHTNESS in kwargs: + brightness = kwargs[ATTR_BRIGHTNESS] + # Convert HomeAssistant brightness (0-255) to device brightness (0-1.0) + value = brightness / 255 + await self.coordinator.async_set_brightness(value) + else: + await self.coordinator.async_turn_on() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light.""" + await self.coordinator.async_turn_off() + + @property + def is_on(self) -> bool | None: + """Return true if the light is on.""" + if self.coordinator.data.state is None: + return None + # Consider the light on if brightness is greater than 0 + return self.coordinator.data.state > 0 + + @property + def brightness(self) -> int | None: + """Return the brightness of the light (0-255).""" + if self.coordinator.data.state is None: + return None + # Convert device brightness (0-1.0) to HomeAssistant brightness (0-255) + return max(0, min(255, round(self.coordinator.data.state * 255))) diff --git a/homeassistant/components/systemnexa2/manifest.json b/homeassistant/components/systemnexa2/manifest.json new file mode 100644 index 00000000000000..dbbe0c05c57606 --- /dev/null +++ b/homeassistant/components/systemnexa2/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "systemnexa2", + "name": "System Nexa 2", + "codeowners": ["@konsulten"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/systemnexa2", + "integration_type": "device", + "iot_class": "local_push", + "quality_scale": "platinum", + "requirements": ["python-sn2==0.4.0"], + "zeroconf": ["_systemnexa2._tcp.local."] +} diff --git a/homeassistant/components/systemnexa2/quality_scale.yaml b/homeassistant/components/systemnexa2/quality_scale.yaml new file mode 100644 index 00000000000000..cb413534cee46f --- /dev/null +++ b/homeassistant/components/systemnexa2/quality_scale.yaml @@ -0,0 +1,82 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No extra service actions in integration + appropriate-polling: + status: exempt + comment: No polling used, push only + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No service actions in integration + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: No events handled in entities + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No configuration parameters implemented, + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: Integration does not use authentication. + 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: + status: exempt + comment: Integration manages single devices, not a hub with multiple devices. + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: Too few to really disable any yet + entity-translations: done + exception-translations: done + icon-translations: + status: exempt + comment: No icons referenced currently + reconfiguration-flow: done + repair-issues: + status: exempt + comment: At the moment there are no repairable situations. + stale-devices: + status: exempt + comment: Not a hub. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/systemnexa2/sensor.py b/homeassistant/components/systemnexa2/sensor.py new file mode 100644 index 00000000000000..b5b16c46cd4f67 --- /dev/null +++ b/homeassistant/components/systemnexa2/sensor.py @@ -0,0 +1,77 @@ +"""Sensor platform for SystemNexa2 integration.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SystemNexa2ConfigEntry, SystemNexa2DataUpdateCoordinator +from .entity import SystemNexa2Entity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class SystemNexa2SensorEntityDescription(SensorEntityDescription): + """Describes SystemNexa2 sensor entity.""" + + value_fn: Callable[[SystemNexa2DataUpdateCoordinator], str | int | None] + + +SENSOR_DESCRIPTIONS: tuple[SystemNexa2SensorEntityDescription, ...] = ( + SystemNexa2SensorEntityDescription( + key="wifi_dbm", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda coordinator: coordinator.data.info_data.wifi_dbm, + entity_registry_enabled_default=False, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SystemNexa2ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up sensors based on a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + SystemNexa2Sensor(coordinator, description) + for description in SENSOR_DESCRIPTIONS + if description.value_fn(coordinator) is not None + ) + + +class SystemNexa2Sensor(SystemNexa2Entity, SensorEntity): + """Representation of a SystemNexa2 sensor.""" + + entity_description: SystemNexa2SensorEntityDescription + + def __init__( + self, + coordinator: SystemNexa2DataUpdateCoordinator, + entity_description: SystemNexa2SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__( + coordinator=coordinator, + key=entity_description.key, + ) + self.entity_description = entity_description + + @property + def native_value(self) -> str | int | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator) diff --git a/homeassistant/components/systemnexa2/strings.json b/homeassistant/components/systemnexa2/strings.json new file mode 100644 index 00000000000000..b4e62314a82be7 --- /dev/null +++ b/homeassistant/components/systemnexa2/strings.json @@ -0,0 +1,74 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "no_connection": "Could not establish connection to `{host}`", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unknown_connection_error": "Unknown error when accessing `{host}`", + "unsupported_model": "Unsupported device model `{model}` version `{sw_version}`", + "wrong_device": "The device at the new hostname/IP address does not match the configured device identity" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "flow_title": "{name}-({model})", + "step": { + "discovery_confirm": { + "description": "Do you want to add the device `{name}` to Home Assistant?", + "title": "Discovered Nexa System 2 device" + }, + "reconfigure": { + "data": { + "host": "[%key:component::systemnexa2::config::step::user::data::host%]" + }, + "data_description": { + "host": "[%key:component::systemnexa2::config::step::user::data_description::host%]" + }, + "description": "Update the IP address or hostname if your device has been moved to a different address on the network" + }, + "user": { + "data": { + "host": "IP/hostname" + }, + "data_description": { + "host": "Hostname or IP address of the device" + } + } + } + }, + "entity": { + "light": { + "light": { + "name": "Light" + } + }, + "switch": { + "433mhz": { + "name": "433 MHz" + }, + "cloud_access": { + "name": "Cloud access" + }, + "led": { + "name": "LED" + }, + "physical_button": { + "name": "Physical button" + }, + "relay_1": { + "name": "Relay" + } + } + }, + "exceptions": { + "device_communication_error": { + "message": "Failed to communicate with the device. Please verify that the device is powered on and connected to the network" + }, + "failed_to_initiate_connection": { + "message": "Failed to initialize device with IP/hostname `{host}`, please verify that the device is powered on and reachable on port 3000" + } + } +} diff --git a/homeassistant/components/systemnexa2/switch.py b/homeassistant/components/systemnexa2/switch.py new file mode 100644 index 00000000000000..035068229a370c --- /dev/null +++ b/homeassistant/components/systemnexa2/switch.py @@ -0,0 +1,140 @@ +"""Switch entity for the SystemNexa2 integration.""" + +from dataclasses import dataclass +from typing import Any, Final + +from sn2.device import OnOffSetting + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SystemNexa2ConfigEntry, SystemNexa2DataUpdateCoordinator +from .entity import SystemNexa2Entity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class SystemNexa2SwitchEntityDescription(SwitchEntityDescription): + """Entity description for SystemNexa switch entities.""" + + +SWITCH_TYPES: Final = [ + SystemNexa2SwitchEntityDescription( + key="433Mhz", + translation_key="433mhz", + entity_category=EntityCategory.CONFIG, + ), + SystemNexa2SwitchEntityDescription( + key="Cloud Access", + translation_key="cloud_access", + entity_category=EntityCategory.CONFIG, + ), + SystemNexa2SwitchEntityDescription( + key="Led", + translation_key="led", + entity_category=EntityCategory.CONFIG, + ), + SystemNexa2SwitchEntityDescription( + key="Physical Button", + translation_key="physical_button", + entity_category=EntityCategory.CONFIG, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SystemNexa2ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up switch and configuration options based on a config entry.""" + coordinator = entry.runtime_data + entities: list[SystemNexa2Entity] = [ + SystemNexa2ConfigurationSwitch(coordinator, switch_type, setting) + for setting_name, setting in coordinator.data.on_off_settings.items() + for switch_type in SWITCH_TYPES + if switch_type.key == setting_name + ] + + if coordinator.data.info_data.dimmable is False: + entities.append( + SystemNexa2SwitchPlug( + coordinator=coordinator, + ) + ) + async_add_entities(entities) + + +class SystemNexa2ConfigurationSwitch(SystemNexa2Entity, SwitchEntity): + """Configuration switch entity for SystemNexa2 devices.""" + + _attr_device_class = SwitchDeviceClass.SWITCH + entity_description: SystemNexa2SwitchEntityDescription + + def __init__( + self, + coordinator: SystemNexa2DataUpdateCoordinator, + description: SystemNexa2SwitchEntityDescription, + setting: OnOffSetting, + ) -> None: + """Initialize the configuration switch.""" + super().__init__(coordinator, description.key) + self.entity_description = description + self._setting = setting + + async def async_turn_on(self, **_kwargs: Any) -> None: + """Turn on the switch.""" + await self.coordinator.async_setting_enable(self._setting) + + async def async_turn_off(self, **_kwargs: Any) -> None: + """Turn off the switch.""" + await self.coordinator.async_setting_disable(self._setting) + + @property + def is_on(self) -> bool: + """Return true if the switch is on.""" + return self.coordinator.data.on_off_settings[ + self.entity_description.key + ].is_enabled() + + +class SystemNexa2SwitchPlug(SystemNexa2Entity, SwitchEntity): + """Representation of a Switch.""" + + _attr_translation_key = "relay_1" + + def __init__( + self, + coordinator: SystemNexa2DataUpdateCoordinator, + ) -> None: + """Initialize the switch.""" + super().__init__( + coordinator=coordinator, + key="relay_1", + ) + + async def async_turn_on(self, **_kwargs: Any) -> None: + """Turn on the switch.""" + await self.coordinator.async_turn_on() + + async def async_turn_off(self, **_kwargs: Any) -> None: + """Turn off the switch.""" + await self.coordinator.async_turn_off() + + async def async_toggle(self, **_kwargs: Any) -> None: + """Toggle the switch.""" + await self.coordinator.async_toggle() + + @property + def is_on(self) -> bool | None: + """Return true if the switch is on.""" + if self.coordinator.data.state is None: + return None + return bool(self.coordinator.data.state) diff --git a/homeassistant/components/tami4/manifest.json b/homeassistant/components/tami4/manifest.json index e09970c341da78..962eb4d62fdcd5 100644 --- a/homeassistant/components/tami4/manifest.json +++ b/homeassistant/components/tami4/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@Guy293"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tami4", + "integration_type": "device", "iot_class": "cloud_polling", "requirements": ["Tami4EdgeAPI==3.0"] } diff --git a/homeassistant/components/tank_utility/sensor.py b/homeassistant/components/tank_utility/sensor.py index e9377e346d4f60..2ccfb48b32d185 100644 --- a/homeassistant/components/tank_utility/sensor.py +++ b/homeassistant/components/tank_utility/sensor.py @@ -74,41 +74,15 @@ def setup_platform( class TankUtilitySensor(SensorEntity): """Representation of a Tank Utility sensor.""" + _attr_native_unit_of_measurement = PERCENTAGE + def __init__(self, email, password, token, device): """Initialize the sensor.""" self._email = email self._password = password self._token = token self._device = device - self._state = None - self._name = f"Tank Utility {self.device}" - self._unit_of_measurement = PERCENTAGE - self._attributes = {} - - @property - def device(self): - """Return the device identifier.""" - return self._device - - @property - def native_value(self): - """Return the state of the device.""" - return self._state - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of the device.""" - return self._unit_of_measurement - - @property - def extra_state_attributes(self): - """Return the attributes of the device.""" - return self._attributes + self._attr_name = f"Tank Utility {device}" def get_data(self): """Get data from the device. @@ -119,7 +93,7 @@ def get_data(self): data = {} try: - data = tank_monitor.get_device_data(self._token, self.device) + data = tank_monitor.get_device_data(self._token, self._device) except requests.exceptions.HTTPError as http_error: if http_error.response.status_code in ( requests.codes.unauthorized, @@ -127,7 +101,7 @@ def get_data(self): ): _LOGGER.debug("Getting new token") self._token = auth.get_token(self._email, self._password, force=True) - data = tank_monitor.get_device_data(self._token, self.device) + data = tank_monitor.get_device_data(self._token, self._device) else: raise data.update(data.pop("device", {})) @@ -137,5 +111,7 @@ def get_data(self): def update(self) -> None: """Set the device state and attributes.""" data = self.get_data() - self._state = round(data[SENSOR_TYPE], SENSOR_ROUNDING_PRECISION) - self._attributes = {k: v for k, v in data.items() if k in SENSOR_ATTRS} + self._attr_native_value = round(data[SENSOR_TYPE], SENSOR_ROUNDING_PRECISION) + self._attr_extra_state_attributes = { + k: v for k, v in data.items() if k in SENSOR_ATTRS + } diff --git a/homeassistant/components/tankerkoenig/manifest.json b/homeassistant/components/tankerkoenig/manifest.json index cb640fd7ec6f7a..1b4f146f35b18e 100644 --- a/homeassistant/components/tankerkoenig/manifest.json +++ b/homeassistant/components/tankerkoenig/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aiotankerkoenig"], "quality_scale": "platinum", - "requirements": ["aiotankerkoenig==0.4.2"] + "requirements": ["aiotankerkoenig==0.5.1"] } diff --git a/homeassistant/components/tapsaff/binary_sensor.py b/homeassistant/components/tapsaff/binary_sensor.py index beba9c91538657..b754b0f2b87012 100644 --- a/homeassistant/components/tapsaff/binary_sensor.py +++ b/homeassistant/components/tapsaff/binary_sensor.py @@ -61,7 +61,7 @@ def name(self): return f"{self._name}" @property - def is_on(self): + def is_on(self) -> bool: """Return true if taps aff.""" return self.data.is_taps_aff diff --git a/homeassistant/components/telegram/notify.py b/homeassistant/components/telegram/notify.py index 6bd4897939a1df..e649514d418f4d 100644 --- a/homeassistant/components/telegram/notify.py +++ b/homeassistant/components/telegram/notify.py @@ -16,6 +16,7 @@ BaseNotificationService, ) from homeassistant.components.telegram_bot import ( + ATTR_CHAT_ID, ATTR_DISABLE_NOTIF, ATTR_DISABLE_WEB_PREV, ATTR_MESSAGE_TAG, @@ -58,7 +59,7 @@ async def async_get_service( hass, DOMAIN, "migrate_notify", - breaks_in_ha_version="2026.5.0", + breaks_in_ha_version="2026.8.0", is_fixable=False, translation_key="migrate_notify", severity=ir.IssueSeverity.WARNING, @@ -80,7 +81,7 @@ def __init__(self, hass, chat_id): def send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a user.""" - service_data = {ATTR_TARGET: kwargs.get(ATTR_TARGET, self._chat_id)} + service_data = {ATTR_CHAT_ID: kwargs.get(ATTR_TARGET, self._chat_id)} data = kwargs.get(ATTR_DATA) # Set message tag diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index e418336eaa269a..fe623c6a215048 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -71,9 +71,9 @@ ATTR_KEYBOARD_INLINE, ATTR_MEDIA_TYPE, ATTR_MESSAGE, + ATTR_MESSAGE_ID, ATTR_MESSAGE_TAG, ATTR_MESSAGE_THREAD_ID, - ATTR_MESSAGEID, ATTR_ONE_TIME_KEYBOARD, ATTR_OPEN_PERIOD, ATTR_OPTIONS, @@ -264,7 +264,7 @@ vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, vol.Optional(ATTR_TITLE): cv.string, vol.Required(ATTR_MESSAGE): cv.string, - vol.Required(ATTR_MESSAGEID): vol.Any( + vol.Required(ATTR_MESSAGE_ID): vol.Any( cv.positive_int, vol.All(cv.string, "last") ), vol.Optional(ATTR_CHAT_ID): vol.Coerce(int), @@ -281,7 +281,7 @@ { vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, - vol.Required(ATTR_MESSAGEID): vol.Any( + vol.Required(ATTR_MESSAGE_ID): vol.Any( cv.positive_int, vol.All(cv.string, "last") ), vol.Optional(ATTR_CHAT_ID): vol.Coerce(int), @@ -311,7 +311,7 @@ { vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, - vol.Required(ATTR_MESSAGEID): vol.Any( + vol.Required(ATTR_MESSAGE_ID): vol.Any( cv.positive_int, vol.All(cv.string, "last") ), vol.Optional(ATTR_CHAT_ID): vol.Coerce(int), @@ -325,7 +325,7 @@ { vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, - vol.Required(ATTR_MESSAGEID): vol.Any( + vol.Required(ATTR_MESSAGE_ID): vol.Any( cv.positive_int, vol.All(cv.string, "last") ), vol.Optional(ATTR_CHAT_ID): vol.Coerce(int), @@ -347,7 +347,7 @@ vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, vol.Optional(ATTR_CHAT_ID): vol.Coerce(int), - vol.Required(ATTR_MESSAGEID): vol.Any( + vol.Required(ATTR_MESSAGE_ID): vol.Any( cv.positive_int, vol.All(cv.string, "last") ), } @@ -364,7 +364,7 @@ SERVICE_SCHEMA_SET_MESSAGE_REACTION = vol.Schema( { vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, - vol.Required(ATTR_MESSAGEID): vol.Any( + vol.Required(ATTR_MESSAGE_ID): vol.Any( cv.positive_int, vol.All(cv.string, "last") ), vol.Optional(ATTR_CHAT_ID): vol.Coerce(int), @@ -468,7 +468,7 @@ async def _async_send_telegram_message(service: ServiceCall) -> ServiceResponse: targets = _build_targets(service) service_responses: JsonValueType = [] - errors: list[tuple[HomeAssistantError, str]] = [] + errors: list[tuple[Exception, str]] = [] # invoke the service for each target for target_config_entry, target_chat_id, target_notify_entity_id in targets: @@ -485,7 +485,7 @@ async def _async_send_telegram_message(service: ServiceCall) -> ServiceResponse: for chat_id, message_id in service_response.items(): formatted_response = { ATTR_CHAT_ID: int(chat_id), - ATTR_MESSAGEID: message_id, + ATTR_MESSAGE_ID: message_id, } if target_notify_entity_id: @@ -495,12 +495,18 @@ async def _async_send_telegram_message(service: ServiceCall) -> ServiceResponse: assert isinstance(service_responses, list) service_responses.extend(formatted_responses) - except HomeAssistantError as ex: + except (HomeAssistantError, TelegramError) as ex: target = target_notify_entity_id or str(target_chat_id) errors.append((ex, target)) if len(errors) == 1: - raise errors[0][0] + if isinstance(errors[0][0], HomeAssistantError): + raise errors[0][0] + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="action_failed", + translation_placeholders={"error": str(errors[0][0])}, + ) from errors[0][0] if len(errors) > 1: error_messages: list[str] = [] @@ -528,7 +534,7 @@ async def _call_service( service_name = service.service kwargs = dict(service.data) - kwargs[ATTR_TARGET] = chat_id + kwargs[ATTR_CHAT_ID] = chat_id messages: dict[str, JsonValueType] | None = None if service_name == SERVICE_SEND_MESSAGE: diff --git a/homeassistant/components/telegram_bot/bot.py b/homeassistant/components/telegram_bot/bot.py index 754dc84305c634..eb27d0138caf63 100644 --- a/homeassistant/components/telegram_bot/bot.py +++ b/homeassistant/components/telegram_bot/bot.py @@ -2,12 +2,11 @@ from abc import abstractmethod import asyncio -from collections.abc import Callable, Sequence +from collections.abc import Awaitable, Callable, Sequence import io import logging import os from pathlib import Path -from ssl import SSLContext from types import MappingProxyType from typing import Any, cast @@ -48,8 +47,8 @@ from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.util.json import JsonValueType -from homeassistant.util.ssl import get_default_context, get_default_no_verify_context from .const import ( ATTR_ARGS, @@ -73,9 +72,9 @@ ATTR_KEYBOARD, ATTR_KEYBOARD_INLINE, ATTR_MESSAGE, + ATTR_MESSAGE_ID, ATTR_MESSAGE_TAG, ATTR_MESSAGE_THREAD_ID, - ATTR_MESSAGEID, ATTR_MSG, ATTR_MSGID, ATTR_ONE_TIME_KEYBOARD, @@ -86,7 +85,6 @@ ATTR_REPLYMARKUP, ATTR_RESIZE_KEYBOARD, ATTR_STICKER_ID, - ATTR_TARGET, ATTR_TEXT, ATTR_TIMEOUT, ATTR_TITLE, @@ -143,6 +141,7 @@ def __init__( """Initialize the bot base class.""" self.hass = hass self.config = config + self.most_recent_chat_id: int | None = None self._bot = bot @abstractmethod @@ -152,8 +151,6 @@ async def shutdown(self) -> None: async def handle_update(self, update: Update, context: CallbackContext) -> bool: """Handle updates from bot application set up by the respective platform.""" _LOGGER.debug("Handling update %s", update) - if not self.authorize_update(update): - return False # establish event type: text, command or callback_query if update.callback_query: @@ -170,6 +167,11 @@ async def handle_update(self, update: Update, context: CallbackContext) -> bool: _LOGGER.warning("Unhandled update: %s", update) return True + self.most_recent_chat_id = event_data[ATTR_CHAT_ID] + + if not self.authorize_update(update): + return False + event_data["bot"] = _get_bot_info(self._bot, self.config) event_context = Context() @@ -320,8 +322,8 @@ def _get_msg_ids( """ message_id: Any | None = None inline_message_id: int | None = None - if ATTR_MESSAGEID in msg_data: - message_id = msg_data[ATTR_MESSAGEID] + if ATTR_MESSAGE_ID in msg_data: + message_id = msg_data[ATTR_MESSAGE_ID] if ( isinstance(message_id, str) and (message_id == "last") @@ -332,35 +334,6 @@ def _get_msg_ids( inline_message_id = msg_data[ATTR_INLINE_MESSAGE_ID] return message_id, inline_message_id - def get_target_chat_ids(self, target: int | list[int] | None) -> list[int]: - """Validate chat_id targets or return default target (first). - - :param target: optional list of integers ([12234, -12345]) - :return list of chat_id targets (integers) - """ - allowed_chat_ids: list[int] = [ - subentry.data[CONF_CHAT_ID] for subentry in self.config.subentries.values() - ] - - if target is None: - return [allowed_chat_ids[0]] - - chat_ids = [target] if isinstance(target, int) else target - valid_chat_ids = [ - chat_id for chat_id in chat_ids if chat_id in allowed_chat_ids - ] - if not valid_chat_ids: - raise ServiceValidationError( - "Invalid chat IDs", - translation_domain=DOMAIN, - translation_key="invalid_chat_ids", - translation_placeholders={ - "chat_ids": ", ".join(str(chat_id) for chat_id in chat_ids), - "bot_name": self.config.title, - }, - ) - return valid_chat_ids - def _get_msg_kwargs(self, data: dict[str, Any]) -> dict[str, Any]: """Get parameters in message data kwargs.""" @@ -460,109 +433,74 @@ def _make_row_inline_keyboard(row_keyboard: Any) -> list[InlineKeyboardButton]: params[ATTR_PARSER] = None return params - async def _send_msgs( + async def _send_msg_formatted( self, - func_send: Callable, - msg_error: str, + func_send: Callable[..., Awaitable[Message]], message_tag: str | None, *args_msg: Any, context: Context | None = None, **kwargs_msg: Any, ) -> dict[str, JsonValueType]: - """Sends a message to each of the targets. - - If there is only 1 targtet, an error is raised if the send fails. - For multiple targets, errors are logged and the caller is responsible for checking which target is successful/failed based on the return value. + """Sends a message and formats the response. :return: dict with chat_id keys and message_id values for successful sends """ - chat_ids = self.get_target_chat_ids(kwargs_msg.pop(ATTR_TARGET, None)) - msg_ids: dict[str, JsonValueType] = {} - for chat_id in chat_ids: - _LOGGER.debug("%s to chat ID %s", func_send.__name__, chat_id) - - for file_type in _FILE_TYPES: - if file_type in kwargs_msg and isinstance( - kwargs_msg[file_type], io.BytesIO - ): - kwargs_msg[file_type].seek(0) - - response: Message = await self._send_msg( - func_send, - msg_error, - message_tag, - chat_id, - *args_msg, - context=context, - suppress_error=len(chat_ids) > 1, - **kwargs_msg, - ) - if response: - msg_ids[str(chat_id)] = response.id + chat_id: int = kwargs_msg.pop(ATTR_CHAT_ID) + _LOGGER.debug("%s to chat ID %s", func_send.__name__, chat_id) + + response: Message = await self._send_msg( + func_send, + message_tag, + chat_id, + *args_msg, + context=context, + **kwargs_msg, + ) - return msg_ids + return {str(chat_id): response.id} async def _send_msg( self, - func_send: Callable, - msg_error: str, + func_send: Callable[..., Awaitable[Any]], message_tag: str | None, *args_msg: Any, context: Context | None = None, - suppress_error: bool = False, **kwargs_msg: Any, ) -> Any: """Send one message.""" - try: - out = await func_send(*args_msg, **kwargs_msg) - if isinstance(out, Message): - chat_id = out.chat_id - message_id = out.message_id - self._last_message_id[chat_id] = message_id - _LOGGER.debug( - "Last message ID: %s (from chat_id %s)", - self._last_message_id, - chat_id, - ) + out = await func_send(*args_msg, **kwargs_msg) + if isinstance(out, Message): + chat_id = out.chat_id + message_id = out.message_id + self._last_message_id[chat_id] = message_id + _LOGGER.debug( + "Last message ID: %s (from chat_id %s)", + self._last_message_id, + chat_id, + ) - event_data: dict[str, Any] = { - ATTR_CHAT_ID: chat_id, - ATTR_MESSAGEID: message_id, - } - if message_tag is not None: - event_data[ATTR_MESSAGE_TAG] = message_tag - if kwargs_msg.get(ATTR_MESSAGE_THREAD_ID) is not None: - event_data[ATTR_MESSAGE_THREAD_ID] = kwargs_msg[ - ATTR_MESSAGE_THREAD_ID - ] - - event_data["bot"] = _get_bot_info(self.bot, self.config) - - self.hass.bus.async_fire( - EVENT_TELEGRAM_SENT, event_data, context=context - ) - async_dispatcher_send( - self.hass, signal(self.bot), EVENT_TELEGRAM_SENT, event_data - ) - except TelegramError as exc: - if not suppress_error: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="action_failed", - translation_placeholders={"error": str(exc)}, - ) from exc + event_data: dict[str, Any] = { + ATTR_CHAT_ID: chat_id, + ATTR_MESSAGE_ID: message_id, + } + if message_tag is not None: + event_data[ATTR_MESSAGE_TAG] = message_tag + if kwargs_msg.get(ATTR_MESSAGE_THREAD_ID) is not None: + event_data[ATTR_MESSAGE_THREAD_ID] = kwargs_msg[ATTR_MESSAGE_THREAD_ID] - _LOGGER.error( - "%s: %s. Args: %s, kwargs: %s", msg_error, exc, args_msg, kwargs_msg + event_data["bot"] = _get_bot_info(self.bot, self.config) + + self.hass.bus.async_fire(EVENT_TELEGRAM_SENT, event_data, context=context) + async_dispatcher_send( + self.hass, signal(self.bot), EVENT_TELEGRAM_SENT, event_data ) - return None return out async def send_message( self, - message: str = "", - target: Any = None, + message: str, + chat_id: int, context: Context | None = None, **kwargs: dict[str, Any], ) -> dict[str, JsonValueType]: @@ -570,12 +508,11 @@ async def send_message( title = kwargs.get(ATTR_TITLE) text = f"{title}\n{message}" if title else message params = self._get_msg_kwargs(kwargs) - return await self._send_msgs( + return await self._send_msg_formatted( self.bot.send_message, - "Error sending message", params[ATTR_MESSAGE_TAG], text, - target=target, + chat_id=chat_id, parse_mode=params[ATTR_PARSER], disable_web_page_preview=params[ATTR_DISABLE_WEB_PREV], disable_notification=params[ATTR_DISABLE_NOTIF], @@ -588,17 +525,15 @@ async def send_message( async def delete_message( self, - chat_id: int | None = None, + chat_id: int, context: Context | None = None, **kwargs: dict[str, Any], ) -> bool: """Delete a previously sent message.""" - chat_id = self.get_target_chat_ids(chat_id)[0] message_id, _ = self._get_msg_ids(kwargs, chat_id) _LOGGER.debug("Delete message %s in chat ID %s", message_id, chat_id) deleted: bool = await self._send_msg( self.bot.delete_message, - "Error deleting message", None, chat_id, message_id, @@ -613,12 +548,11 @@ async def delete_message( async def edit_message_media( self, media_type: str, - chat_id: int | None = None, + chat_id: int, context: Context | None = None, **kwargs: Any, ) -> Any: "Edit message media of a previously sent message." - chat_id = self.get_target_chat_ids(chat_id)[0] message_id, inline_message_id = self._get_msg_ids(kwargs, chat_id) params = self._get_msg_kwargs(kwargs) _LOGGER.debug( @@ -635,11 +569,7 @@ async def edit_message_media( username=kwargs.get(ATTR_USERNAME, ""), password=kwargs.get(ATTR_PASSWORD, ""), authentication=kwargs.get(ATTR_AUTHENTICATION), - verify_ssl=( - get_default_context() - if kwargs.get(ATTR_VERIFY_SSL, False) - else get_default_no_verify_context() - ), + verify_ssl=kwargs.get(ATTR_VERIFY_SSL, False), ) media: InputMedia @@ -676,7 +606,6 @@ async def edit_message_media( return await self._send_msg( self.bot.edit_message_media, - "Error editing message media", params[ATTR_MESSAGE_TAG], media=media, chat_id=chat_id, @@ -690,12 +619,11 @@ async def edit_message_media( async def edit_message( self, type_edit: str, - chat_id: int | None = None, + chat_id: int, context: Context | None = None, **kwargs: dict[str, Any], ) -> Any: """Edit a previously sent message.""" - chat_id = self.get_target_chat_ids(chat_id)[0] message_id, inline_message_id = self._get_msg_ids(kwargs, chat_id) params = self._get_msg_kwargs(kwargs) _LOGGER.debug( @@ -711,7 +639,6 @@ async def edit_message( _LOGGER.debug("Editing message with ID %s", message_id or inline_message_id) return await self._send_msg( self.bot.edit_message_text, - "Error editing text message", params[ATTR_MESSAGE_TAG], text, chat_id=chat_id, @@ -726,7 +653,6 @@ async def edit_message( if type_edit == SERVICE_EDIT_CAPTION: return await self._send_msg( self.bot.edit_message_caption, - "Error editing message attributes", params[ATTR_MESSAGE_TAG], chat_id=chat_id, message_id=message_id, @@ -740,7 +666,6 @@ async def edit_message( return await self._send_msg( self.bot.edit_message_reply_markup, - "Error editing message attributes", params[ATTR_MESSAGE_TAG], chat_id=chat_id, message_id=message_id, @@ -768,7 +693,6 @@ async def answer_callback_query( ) await self._send_msg( self.bot.answer_callback_query, - "Error sending answer callback query", params[ATTR_MESSAGE_TAG], callback_query_id, text=message, @@ -779,25 +703,23 @@ async def answer_callback_query( async def send_chat_action( self, + chat_id: int, chat_action: str = "", - target: Any = None, context: Context | None = None, **kwargs: Any, ) -> dict[str, JsonValueType]: """Send a chat action to pre-allowed chat IDs.""" result: dict[str, JsonValueType] = {} - for chat_id in self.get_target_chat_ids(target): - _LOGGER.debug("Send action %s in chat ID %s", chat_action, chat_id) - is_successful = await self._send_msg( - self.bot.send_chat_action, - "Error sending action", - None, - chat_id=chat_id, - action=chat_action, - message_thread_id=kwargs.get(ATTR_MESSAGE_THREAD_ID), - context=context, - ) - result[str(chat_id)] = is_successful + _LOGGER.debug("Send action %s in chat ID %s", chat_action, chat_id) + is_successful = await self._send_msg( + self.bot.send_chat_action, + None, + chat_id=chat_id, + action=chat_action, + message_thread_id=kwargs.get(ATTR_MESSAGE_THREAD_ID), + context=context, + ) + result[str(chat_id)] = is_successful return result async def send_file( @@ -815,19 +737,14 @@ async def send_file( username=kwargs.get(ATTR_USERNAME, ""), password=kwargs.get(ATTR_PASSWORD, ""), authentication=kwargs.get(ATTR_AUTHENTICATION), - verify_ssl=( - get_default_context() - if kwargs.get(ATTR_VERIFY_SSL, False) - else get_default_no_verify_context() - ), + verify_ssl=kwargs.get(ATTR_VERIFY_SSL, False), ) if file_type == SERVICE_SEND_PHOTO: - return await self._send_msgs( + return await self._send_msg_formatted( self.bot.send_photo, - "Error sending photo", params[ATTR_MESSAGE_TAG], - target=kwargs.get(ATTR_TARGET), + chat_id=kwargs[ATTR_CHAT_ID], photo=file_content, caption=kwargs.get(ATTR_CAPTION), disable_notification=params[ATTR_DISABLE_NOTIF], @@ -840,11 +757,10 @@ async def send_file( ) if file_type == SERVICE_SEND_STICKER: - return await self._send_msgs( + return await self._send_msg_formatted( self.bot.send_sticker, - "Error sending sticker", params[ATTR_MESSAGE_TAG], - target=kwargs.get(ATTR_TARGET), + chat_id=kwargs[ATTR_CHAT_ID], sticker=file_content, disable_notification=params[ATTR_DISABLE_NOTIF], reply_to_message_id=params[ATTR_REPLY_TO_MSGID], @@ -855,11 +771,10 @@ async def send_file( ) if file_type == SERVICE_SEND_VIDEO: - return await self._send_msgs( + return await self._send_msg_formatted( self.bot.send_video, - "Error sending video", params[ATTR_MESSAGE_TAG], - target=kwargs.get(ATTR_TARGET), + chat_id=kwargs[ATTR_CHAT_ID], video=file_content, caption=kwargs.get(ATTR_CAPTION), disable_notification=params[ATTR_DISABLE_NOTIF], @@ -872,11 +787,10 @@ async def send_file( ) if file_type == SERVICE_SEND_DOCUMENT: - return await self._send_msgs( + return await self._send_msg_formatted( self.bot.send_document, - "Error sending document", params[ATTR_MESSAGE_TAG], - target=kwargs.get(ATTR_TARGET), + chat_id=kwargs[ATTR_CHAT_ID], document=file_content, caption=kwargs.get(ATTR_CAPTION), disable_notification=params[ATTR_DISABLE_NOTIF], @@ -889,11 +803,10 @@ async def send_file( ) if file_type == SERVICE_SEND_VOICE: - return await self._send_msgs( + return await self._send_msg_formatted( self.bot.send_voice, - "Error sending voice", params[ATTR_MESSAGE_TAG], - target=kwargs.get(ATTR_TARGET), + chat_id=kwargs[ATTR_CHAT_ID], voice=file_content, caption=kwargs.get(ATTR_CAPTION), disable_notification=params[ATTR_DISABLE_NOTIF], @@ -905,11 +818,10 @@ async def send_file( ) # SERVICE_SEND_ANIMATION - return await self._send_msgs( + return await self._send_msg_formatted( self.bot.send_animation, - "Error sending animation", params[ATTR_MESSAGE_TAG], - target=kwargs.get(ATTR_TARGET), + chat_id=kwargs[ATTR_CHAT_ID], animation=file_content, caption=kwargs.get(ATTR_CAPTION), disable_notification=params[ATTR_DISABLE_NOTIF], @@ -931,11 +843,10 @@ async def send_sticker( stickerid = kwargs.get(ATTR_STICKER_ID) if stickerid: - return await self._send_msgs( + return await self._send_msg_formatted( self.bot.send_sticker, - "Error sending sticker", params[ATTR_MESSAGE_TAG], - target=kwargs.get(ATTR_TARGET), + chat_id=kwargs[ATTR_CHAT_ID], sticker=stickerid, disable_notification=params[ATTR_DISABLE_NOTIF], reply_to_message_id=params[ATTR_REPLY_TO_MSGID], @@ -950,7 +861,6 @@ async def send_location( self, latitude: Any, longitude: Any, - target: Any = None, context: Context | None = None, **kwargs: dict[str, Any], ) -> dict[str, JsonValueType]: @@ -958,11 +868,10 @@ async def send_location( latitude = float(latitude) longitude = float(longitude) params = self._get_msg_kwargs(kwargs) - return await self._send_msgs( + return await self._send_msg_formatted( self.bot.send_location, - "Error sending location", params[ATTR_MESSAGE_TAG], - target=target, + chat_id=kwargs[ATTR_CHAT_ID], latitude=latitude, longitude=longitude, disable_notification=params[ATTR_DISABLE_NOTIF], @@ -978,18 +887,16 @@ async def send_poll( options: Sequence[str | InputPollOption], is_anonymous: bool | None, allows_multiple_answers: bool | None, - target: Any = None, context: Context | None = None, **kwargs: dict[str, Any], ) -> dict[str, JsonValueType]: """Send a poll.""" params = self._get_msg_kwargs(kwargs) openperiod = kwargs.get(ATTR_OPEN_PERIOD) - return await self._send_msgs( + return await self._send_msg_formatted( self.bot.send_poll, - "Error sending poll", params[ATTR_MESSAGE_TAG], - target=target, + chat_id=kwargs[ATTR_CHAT_ID], question=question, options=options, is_anonymous=is_anonymous, @@ -1004,27 +911,23 @@ async def send_poll( async def leave_chat( self, - chat_id: int | None = None, + chat_id: int, context: Context | None = None, **kwargs: dict[str, Any], ) -> Any: """Remove bot from chat.""" - chat_id = self.get_target_chat_ids(chat_id)[0] _LOGGER.debug("Leave from chat ID %s", chat_id) - return await self._send_msg( - self.bot.leave_chat, "Error leaving chat", None, chat_id, context=context - ) + return await self._send_msg(self.bot.leave_chat, None, chat_id, context=context) async def set_message_reaction( self, reaction: str, - chat_id: int | None = None, + chat_id: int, is_big: bool = False, context: Context | None = None, **kwargs: dict[str, Any], ) -> None: """Set the bot's reaction for a given message.""" - chat_id = self.get_target_chat_ids(chat_id)[0] message_id, _ = self._get_msg_ids(kwargs, chat_id) params = self._get_msg_kwargs(kwargs) @@ -1038,7 +941,6 @@ async def set_message_reaction( await self._send_msg( self.bot.set_message_reaction, - "Error setting message reaction", params[ATTR_MESSAGE_TAG], chat_id, message_id, @@ -1061,7 +963,6 @@ async def download_file( directory_path = self.hass.config.path(DOMAIN) file: File = await self._send_msg( self.bot.get_file, - "Error getting file", None, file_id=file_id, context=context, @@ -1122,12 +1023,14 @@ def initialize_bot(hass: HomeAssistant, p_config: MappingProxyType[str, Any]) -> read_timeout=read_timeout, media_write_timeout=media_write_timeout, ) + get_updates_request = HTTPXRequest(proxy=proxy) else: request = HTTPXRequest( connection_pool_size=8, read_timeout=read_timeout, media_write_timeout=media_write_timeout, ) + get_updates_request = None base_url: str = p_config[CONF_API_ENDPOINT] @@ -1136,6 +1039,7 @@ def initialize_bot(hass: HomeAssistant, p_config: MappingProxyType[str, Any]) -> base_url=f"{base_url}/bot", base_file_url=f"{base_url}/file/bot", request=request, + get_updates_request=get_updates_request, ) @@ -1146,7 +1050,7 @@ async def load_data( username: str, password: str, authentication: str | None, - verify_ssl: SSLContext, + verify_ssl: bool, num_retries: int = 5, ) -> io.BytesIO: """Load data into ByteIO/File container from a source.""" @@ -1162,33 +1066,29 @@ async def load_data( elif authentication == HTTP_BASIC_AUTHENTICATION: params["auth"] = httpx.BasicAuth(username, password) - if verify_ssl is not None: - params["verify"] = verify_ssl - retry_num = 0 - async with httpx.AsyncClient( - timeout=DEFAULT_TIMEOUT_SECONDS, headers=headers, **params - ) as client: + async with get_async_client(hass, verify_ssl) as client: while retry_num < num_retries: try: - req = await client.get(url) + response = await client.get( + url, headers=headers, timeout=DEFAULT_TIMEOUT_SECONDS, **params + ) except (httpx.HTTPError, httpx.InvalidURL) as err: raise HomeAssistantError( - f"Failed to load URL: {err!s}", translation_domain=DOMAIN, translation_key="failed_to_load_url", translation_placeholders={"error": str(err)}, ) from err - if req.status_code != 200: + if response.status_code != 200: _LOGGER.warning( "Status code %s (retry #%s) loading %s", - req.status_code, + response.status_code, retry_num + 1, url, ) else: - data = io.BytesIO(req.content) + data = io.BytesIO(response.content) if data.read(): data.seek(0) data.name = url @@ -1201,23 +1101,20 @@ async def load_data( 1 ) # Add a sleep to allow other async operations to proceed raise HomeAssistantError( - f"Failed to load URL: {req.status_code}", translation_domain=DOMAIN, translation_key="failed_to_load_url", - translation_placeholders={"error": str(req.status_code)}, + translation_placeholders={"error": str(response.status_code)}, ) elif filepath is not None: if hass.config.is_allowed_path(filepath): return await hass.async_add_executor_job(_read_file_as_bytesio, filepath) raise ServiceValidationError( - "File path has not been configured in allowlist_external_dirs.", translation_domain=DOMAIN, translation_key="allowlist_external_dirs_error", ) else: raise ServiceValidationError( - "URL or File is required.", translation_domain=DOMAIN, translation_key="missing_input", translation_placeholders={"field": "URL or File"}, @@ -1232,7 +1129,6 @@ def _validate_credentials_input( and not username ): raise ServiceValidationError( - "Username is required.", translation_domain=DOMAIN, translation_key="missing_input", translation_placeholders={"field": "Username"}, @@ -1248,7 +1144,6 @@ def _validate_credentials_input( and not password ): raise ServiceValidationError( - "Password is required.", translation_domain=DOMAIN, translation_key="missing_input", translation_placeholders={"field": "Password"}, @@ -1264,7 +1159,6 @@ def _read_file_as_bytesio(file_path: str) -> io.BytesIO: return data except OSError as err: raise HomeAssistantError( - f"Failed to load file: {err!s}", translation_domain=DOMAIN, translation_key="failed_to_load_file", translation_placeholders={"error": str(err)}, diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index 5217f26742bee6..09f67904cb4543 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -62,8 +62,8 @@ DESCRIPTION_PLACEHOLDERS: dict[str, str] = { "botfather_username": "@BotFather", "botfather_url": "https://t.me/botfather", - "getidsbot_username": "@GetIDs Bot", - "getidsbot_url": "https://t.me/getidsbot", + "id_bot_username": "@id_bot", + "id_bot_url": "https://t.me/id_bot", "socks_url": "socks5://username:password@proxy_ip:proxy_port", # used in advanced settings section "default_api_endpoint": DEFAULT_API_ENDPOINT, @@ -410,7 +410,10 @@ def _validate_webhooks( "URL is required since you have not configured an external URL in Home Assistant" ) return - elif not url.startswith("https"): + elif ( + not url.startswith("https") + and self._step_user_data[CONF_API_ENDPOINT] == DEFAULT_API_ENDPOINT + ): errors["base"] = "invalid_url" description_placeholders[ERROR_FIELD] = "URL" description_placeholders[ERROR_MESSAGE] = "URL must start with https" @@ -611,14 +614,71 @@ async def async_step_user( errors["base"] = "chat_not_found" + service: TelegramNotificationService = self._get_entry().runtime_data + description_placeholders = DESCRIPTION_PLACEHOLDERS.copy() + description_placeholders["bot_username"] = f"@{service.bot.username}" + description_placeholders["bot_url"] = f"https://t.me/{service.bot.username}" + + # suggest chat id based on the most recent chat + suggested_values = {} + description_placeholders["most_recent_chat"] = "Not available" + try: + most_recent_chat = await _get_most_recent_chat(service) + except TelegramError as err: + _LOGGER.warning("Error occurred while fetching recent chat: %s", err) + most_recent_chat = None + if most_recent_chat is not None: + suggested_values[CONF_CHAT_ID] = most_recent_chat[0] + + description_placeholders["most_recent_chat"] = ( + f"{most_recent_chat[1]} ({most_recent_chat[0]})" + if most_recent_chat[1] + else str(most_recent_chat[0]) + ) + return self.async_show_form( step_id="user", - data_schema=vol.Schema({vol.Required(CONF_CHAT_ID): vol.Coerce(int)}), - description_placeholders=DESCRIPTION_PLACEHOLDERS, + data_schema=self.add_suggested_values_to_schema( + vol.Schema({vol.Required(CONF_CHAT_ID): vol.Coerce(int)}), + suggested_values, + ), + description_placeholders=description_placeholders, errors=errors, ) +async def _get_most_recent_chat( + service: TelegramNotificationService, +) -> tuple[int, str | None] | None: + """Get the most recent chat ID and name. + + For broadcast bot, this is retrieved using get_updates() to find the most recent message received. + For polling or webhook bot, this is retrieved from the runtime data which is updated whenever a message is received. + """ + + if service.app is not None: + # this is either polling or webhook bot + + if service.app.most_recent_chat_id is None: + return None + + chat = await service.bot.get_chat(service.app.most_recent_chat_id) + return (service.app.most_recent_chat_id, chat.effective_name) + + # broadcast bot + updates = await service.bot.get_updates(offset=0) + if updates: + last_update = updates[-1] + if last_update.effective_chat: + chat_name = last_update.effective_chat.effective_name + return ( + last_update.effective_chat.id, + chat_name, + ) + + return None + + async def _async_get_chat_name(bot: Bot, chat_id: int) -> str: try: chat_info: ChatFullInfo = await bot.get_chat(chat_id) diff --git a/homeassistant/components/telegram_bot/const.py b/homeassistant/components/telegram_bot/const.py index a950c82584030d..b61554db9fe27c 100644 --- a/homeassistant/components/telegram_bot/const.py +++ b/homeassistant/components/telegram_bot/const.py @@ -102,7 +102,7 @@ ATTR_RESIZE_KEYBOARD = "resize_keyboard" ATTR_ONE_TIME_KEYBOARD = "one_time_keyboard" ATTR_KEYBOARD_INLINE = "inline_keyboard" -ATTR_MESSAGEID = "message_id" +ATTR_MESSAGE_ID = "message_id" ATTR_INLINE_MESSAGE_ID = "inline_message_id" ATTR_MEDIA_TYPE = "media_type" ATTR_MSG = "message" diff --git a/homeassistant/components/telegram_bot/manifest.json b/homeassistant/components/telegram_bot/manifest.json index 0d320cfe3b0887..48bf0c3a270478 100644 --- a/homeassistant/components/telegram_bot/manifest.json +++ b/homeassistant/components/telegram_bot/manifest.json @@ -5,8 +5,9 @@ "config_flow": true, "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/telegram_bot", + "integration_type": "service", "iot_class": "cloud_push", "loggers": ["telegram"], - "quality_scale": "silver", - "requirements": ["python-telegram-bot[socks]==22.1"] + "quality_scale": "gold", + "requirements": ["python-telegram-bot[socks]==22.6"] } diff --git a/homeassistant/components/telegram_bot/quality_scale.yaml b/homeassistant/components/telegram_bot/quality_scale.yaml index d5aeba8384f275..0975adb13d4a34 100644 --- a/homeassistant/components/telegram_bot/quality_scale.yaml +++ b/homeassistant/components/telegram_bot/quality_scale.yaml @@ -16,8 +16,7 @@ rules: docs-removal-instructions: done entity-event-setup: status: exempt - comment: | - The integration does not provide any entities. + comment: Entities do not explicitly subscribe to events. entity-unique-id: done has-entity-name: done runtime-data: done @@ -47,7 +46,7 @@ rules: test-coverage: done # Gold - devices: todo + devices: done diagnostics: done discovery-update-info: status: exempt @@ -55,29 +54,39 @@ rules: discovery: status: exempt comment: the service cannot be discovered - docs-data-update: todo - docs-examples: todo + docs-data-update: done + docs-examples: done docs-known-limitations: done docs-supported-devices: status: exempt comment: the integration is a service - docs-supported-functions: todo + 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 - entity-translations: todo - exception-translations: todo - icon-translations: todo + docs-use-cases: done + dynamic-devices: + status: exempt + comment: There is always one device per config entry. + entity-category: + status: exempt + comment: Entities do not require a specific category. + entity-device-class: + status: exempt + comment: Entities do not have a specific device class. + entity-disabled-by-default: + status: exempt + comment: No noisy/non-essential entities. + entity-translations: done + exception-translations: done + icon-translations: done reconfiguration-flow: done - repair-issues: todo + repair-issues: + status: exempt + comment: Integration does not raise repair issues. stale-devices: status: exempt comment: only one device per entry, is deleted with the entry. # Platinum - async-dependency: todo + async-dependency: done inject-websession: todo strict-typing: done diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index 2b3a1775bc02ef..c736a8bcaa0efc 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -790,7 +790,6 @@ edit_message: filter: domain: notify integration: telegram_bot - reorder: true message_id: required: true example: "{{ trigger.event.data.message.message_id }}" @@ -843,7 +842,6 @@ edit_message_media: filter: domain: notify integration: telegram_bot - reorder: true message_id: required: true example: "{{ trigger.event.data.message.message_id }}" @@ -922,7 +920,6 @@ edit_caption: filter: domain: notify integration: telegram_bot - reorder: true message_id: required: true example: "{{ trigger.event.data.message.message_id }}" @@ -960,7 +957,6 @@ edit_replymarkup: filter: domain: notify integration: telegram_bot - reorder: true message_id: required: true example: "{{ trigger.event.data.message.message_id }}" @@ -1015,7 +1011,6 @@ delete_message: filter: domain: notify integration: telegram_bot - reorder: true message_id: required: true example: "{{ trigger.event.data.message.message_id }}" @@ -1042,7 +1037,6 @@ leave_chat: filter: domain: notify integration: telegram_bot - reorder: true advanced: collapsed: true fields: @@ -1064,7 +1058,6 @@ set_message_reaction: filter: domain: notify integration: telegram_bot - reorder: true message_id: required: true example: 54321 diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index eb2ade5d1986b9..840b926bd5a00a 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -105,7 +105,7 @@ "data_description": { "chat_id": "ID representing the user or group chat to which messages can be sent." }, - "description": "To get your chat ID, follow these steps:\n\n1. Open Telegram and start a chat with [{getidsbot_username}]({getidsbot_url}).\n1. Send any message to the bot.\n1. Your chat ID is in the `id` field of the bot's response.", + "description": "Before you proceed, send any message to your bot: [{bot_username}]({bot_url}). This is required because Telegram prevents bots from initiating chats with users.\n\nThen follow these steps to get your chat ID:\n\n1. Open Telegram and start a chat with [{id_bot_username}]({id_bot_url}).\n1. Send any message to the bot.\n1. Your chat ID is in the `ID` field of the bot's response.\n\nMost recent chat: {most_recent_chat}", "title": "Add chat" } } diff --git a/homeassistant/components/tellduslive/binary_sensor.py b/homeassistant/components/tellduslive/binary_sensor.py index 653017086462b9..bfa3f25f7357a3 100644 --- a/homeassistant/components/tellduslive/binary_sensor.py +++ b/homeassistant/components/tellduslive/binary_sensor.py @@ -36,6 +36,6 @@ class TelldusLiveSensor(TelldusLiveEntity, BinarySensorEntity): _attr_name = None @property - def is_on(self): + def is_on(self) -> bool: """Return true if switch is on.""" return self.device.is_on diff --git a/homeassistant/components/tellduslive/entity.py b/homeassistant/components/tellduslive/entity.py index 5366e4c27dfa0a..35a733dd1b45c7 100644 --- a/homeassistant/components/tellduslive/entity.py +++ b/homeassistant/components/tellduslive/entity.py @@ -2,6 +2,7 @@ from datetime import datetime import logging +from typing import Any from tellduslive import BATTERY_LOW, BATTERY_OK, BATTERY_UNKNOWN @@ -68,7 +69,7 @@ def available(self) -> bool: return self._client.is_available(self.device_id) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" attrs = {} if self._battery_level: diff --git a/homeassistant/components/tellduslive/light.py b/homeassistant/components/tellduslive/light.py index 9f291bb845a081..86fdb4d1d64de3 100644 --- a/homeassistant/components/tellduslive/light.py +++ b/homeassistant/components/tellduslive/light.py @@ -53,12 +53,12 @@ def changed(self): self.schedule_update_ha_state() @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" return self.device.dim_level @property - def is_on(self): + def is_on(self) -> bool: """Return true if light is on.""" return self.device.is_on diff --git a/homeassistant/components/tellduslive/manifest.json b/homeassistant/components/tellduslive/manifest.json index 4ebf1a334bd66b..07795c2b2bf9d4 100644 --- a/homeassistant/components/tellduslive/manifest.json +++ b/homeassistant/components/tellduslive/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@fredrike"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tellduslive", + "integration_type": "hub", "iot_class": "cloud_polling", "requirements": ["tellduslive==0.10.12"] } diff --git a/homeassistant/components/tellduslive/switch.py b/homeassistant/components/tellduslive/switch.py index 3ca2ba066ab18e..346417f89895dc 100644 --- a/homeassistant/components/tellduslive/switch.py +++ b/homeassistant/components/tellduslive/switch.py @@ -38,7 +38,7 @@ class TelldusLiveSwitch(TelldusLiveEntity, SwitchEntity): _attr_name = None @property - def is_on(self): + def is_on(self) -> bool: """Return true if switch is on.""" return self.device.is_on diff --git a/homeassistant/components/tellstick/entity.py b/homeassistant/components/tellstick/entity.py index 5be3d1f48f43f1..966f84c8549b3c 100644 --- a/homeassistant/components/tellstick/entity.py +++ b/homeassistant/components/tellstick/entity.py @@ -30,7 +30,6 @@ class TellstickDevice(Entity): def __init__(self, tellcore_device, signal_repetitions): """Init the Tellstick device.""" self._signal_repetitions = signal_repetitions - self._state = None self._requested_state = None self._requested_data = None self._repeats_left = 0 @@ -48,11 +47,6 @@ async def async_added_to_hass(self) -> None: ) ) - @property - def is_on(self): - """Return true if the device is on.""" - return self._state - def _parse_ha_data(self, kwargs): """Turn the value from HA into something useful.""" raise NotImplementedError diff --git a/homeassistant/components/tellstick/light.py b/homeassistant/components/tellstick/light.py index 0b7878cd10e422..4b335f69558664 100644 --- a/homeassistant/components/tellstick/light.py +++ b/homeassistant/components/tellstick/light.py @@ -52,7 +52,7 @@ def __init__(self, tellcore_device, signal_repetitions): self._brightness = 255 @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" return self._brightness @@ -74,11 +74,11 @@ def _update_model(self, new_state, data): # _brightness is not defined when called from super try: - self._state = self._brightness > 0 + self._attr_is_on = self._brightness > 0 except AttributeError: - self._state = True + self._attr_is_on = True else: - self._state = False + self._attr_is_on = False def _send_device_command(self, requested_state, requested_data): """Let tellcore update the actual device to the requested state.""" diff --git a/homeassistant/components/tellstick/switch.py b/homeassistant/components/tellstick/switch.py index fc9a44ef66c687..6179daa3f24674 100644 --- a/homeassistant/components/tellstick/switch.py +++ b/homeassistant/components/tellstick/switch.py @@ -53,7 +53,7 @@ def _parse_tellcore_data(self, tellcore_data): def _update_model(self, new_state, data): """Update the device entity state to match the arguments.""" - self._state = new_state + self._attr_is_on = new_state def _send_device_command(self, requested_state, requested_data): """Let tellcore update the actual device to the requested state.""" diff --git a/homeassistant/components/teltonika/__init__.py b/homeassistant/components/teltonika/__init__.py new file mode 100644 index 00000000000000..56685afc95738d --- /dev/null +++ b/homeassistant/components/teltonika/__init__.py @@ -0,0 +1,70 @@ +"""The Teltonika integration.""" + +from __future__ import annotations + +import logging + +from teltasync import Teltasync + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_USERNAME, + CONF_VERIFY_SSL, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import TeltonikaDataUpdateCoordinator +from .util import normalize_url + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.SENSOR] + +type TeltonikaConfigEntry = ConfigEntry[TeltonikaDataUpdateCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: TeltonikaConfigEntry) -> bool: + """Set up Teltonika from a config entry.""" + host = entry.data[CONF_HOST] + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + validate_ssl = entry.data.get(CONF_VERIFY_SSL, False) + session = async_get_clientsession(hass) + + base_url = normalize_url(host) + + client = Teltasync( + base_url=f"{base_url}/api", + username=username, + password=password, + session=session, + verify_ssl=validate_ssl, + ) + + # Create coordinator + coordinator = TeltonikaDataUpdateCoordinator(hass, client, entry, base_url) + + # Fetch initial data and set up device info + await coordinator.async_config_entry_first_refresh() + + assert coordinator.device_info is not None + + # Store runtime data + entry.runtime_data = coordinator + + # Set up platforms + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: TeltonikaConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + await entry.runtime_data.client.close() + + return unload_ok diff --git a/homeassistant/components/teltonika/config_flow.py b/homeassistant/components/teltonika/config_flow.py new file mode 100644 index 00000000000000..2d6f06bc35d888 --- /dev/null +++ b/homeassistant/components/teltonika/config_flow.py @@ -0,0 +1,291 @@ +"""Config flow for the Teltonika integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from teltasync import Teltasync, TeltonikaAuthenticationError, TeltonikaConnectionError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo + +from .const import DOMAIN +from .util import get_url_variants + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_VERIFY_SSL, default=False): bool, + } +) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + session = async_get_clientsession(hass) + host = data[CONF_HOST] + + last_error: Exception | None = None + + for base_url in get_url_variants(host): + client = Teltasync( + base_url=f"{base_url}/api", + username=data[CONF_USERNAME], + password=data[CONF_PASSWORD], + session=session, + verify_ssl=data.get(CONF_VERIFY_SSL, True), + ) + + try: + device_info = await client.get_device_info() + auth_valid = await client.validate_credentials() + except TeltonikaConnectionError as err: + _LOGGER.debug( + "Failed to connect to Teltonika device at %s: %s", base_url, err + ) + last_error = err + continue + except TeltonikaAuthenticationError as err: + _LOGGER.error("Authentication failed: %s", err) + raise InvalidAuth from err + finally: + await client.close() + + if not auth_valid: + raise InvalidAuth + + return { + "title": device_info.device_name, + "device_id": device_info.device_identifier, + "host": base_url, + } + + _LOGGER.error("Cannot connect to device after trying all schemas") + raise CannotConnect from last_error + + +class TeltonikaConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Teltonika.""" + + VERSION = 1 + MINOR_VERSION = 1 + _discovered_host: 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: + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + # Set unique ID to prevent duplicates + await self.async_set_unique_id(info["device_id"]) + self._abort_if_unique_id_configured() + + data_to_store = dict(user_input) + if "host" in info: + data_to_store[CONF_HOST] = info["host"] + + return self.async_create_entry( + title=info["title"], + data=data_to_store, + ) + + 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 reauth when authentication fails.""" + 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.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + + if user_input is not None: + data = { + CONF_HOST: reauth_entry.data[CONF_HOST], + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_VERIFY_SSL: reauth_entry.data.get(CONF_VERIFY_SSL, False), + } + try: + # Validate new credentials against the configured host + info = await validate_input(self.hass, data) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception during reauth") + errors["base"] = "unknown" + else: + # Verify reauth is for the same device + await self.async_set_unique_id(info["device_id"]) + self._abort_if_unique_id_mismatch(reason="wrong_account") + + return self.async_update_reload_and_abort( + reauth_entry, + data_updates=user_input, + ) + + reauth_schema = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ) + + suggested = {**reauth_entry.data, **(user_input or {})} + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema(reauth_schema, suggested), + errors=errors, + description_placeholders={ + "name": reauth_entry.title, + "host": reauth_entry.data[CONF_HOST], + }, + ) + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery.""" + host = discovery_info.ip + + # Store discovered host for later use + self._discovered_host = host + + # Try to get device info without authentication to get device identifier and name + session = async_get_clientsession(self.hass) + + for base_url in get_url_variants(host): + client = Teltasync( + base_url=f"{base_url}/api", + username="", # No credentials yet + password="", + session=session, + verify_ssl=False, # Teltonika devices use self-signed certs by default + ) + + try: + # Get device info from unauthorized endpoint + device_info = await client.get_device_info() + device_name = device_info.device_name + device_id = device_info.device_identifier + break + except TeltonikaConnectionError: + # Connection failed, try next URL variant + continue + finally: + await client.close() + else: + # No URL variant worked, device not reachable, don't autodiscover + return self.async_abort(reason="cannot_connect") + + # Set unique ID and check for existing conf + await self.async_set_unique_id(device_id) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + + # Store discovery info for the user step + self.context["title_placeholders"] = { + "name": device_name, + "host": host, + } + + # Proceed to confirmation step to get credentials + return await self.async_step_dhcp_confirm() + + async def async_step_dhcp_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm DHCP discovery and get credentials.""" + errors: dict[str, str] = {} + + if user_input is not None: + # Get the host from the discovery + host = getattr(self, "_discovered_host", "") + + try: + # Validate credentials with discovered host + data = { + CONF_HOST: host, + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_VERIFY_SSL: False, + } + info = await validate_input(self.hass, data) + + # Update unique ID to device identifier if we didn't get it during discovery + await self.async_set_unique_id( + info["device_id"], raise_on_progress=False + ) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=info["title"], + data={ + CONF_HOST: info["host"], + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_VERIFY_SSL: False, + }, + ) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception during DHCP confirm") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="dhcp_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + description_placeholders=self.context.get("title_placeholders", {}), + ) diff --git a/homeassistant/components/teltonika/const.py b/homeassistant/components/teltonika/const.py new file mode 100644 index 00000000000000..5a1f0f66211c07 --- /dev/null +++ b/homeassistant/components/teltonika/const.py @@ -0,0 +1,3 @@ +"""Constants for the Teltonika integration.""" + +DOMAIN = "teltonika" diff --git a/homeassistant/components/teltonika/coordinator.py b/homeassistant/components/teltonika/coordinator.py new file mode 100644 index 00000000000000..7d1a614d1414e6 --- /dev/null +++ b/homeassistant/components/teltonika/coordinator.py @@ -0,0 +1,129 @@ +"""DataUpdateCoordinator for Teltonika.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import TYPE_CHECKING, Any + +from aiohttp import ClientResponseError, ContentTypeError +from teltasync import Teltasync, TeltonikaAuthenticationError, TeltonikaConnectionError +from teltasync.error_codes import TeltonikaErrorCode +from teltasync.modems import Modems + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +if TYPE_CHECKING: + from . import TeltonikaConfigEntry + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=30) +AUTH_ERROR_CODES = frozenset( + { + TeltonikaErrorCode.UNAUTHORIZED_ACCESS, + TeltonikaErrorCode.LOGIN_FAILED, + TeltonikaErrorCode.INVALID_JWT_TOKEN, + } +) + + +class TeltonikaDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching Teltonika data.""" + + device_info: DeviceInfo + + def __init__( + self, + hass: HomeAssistant, + client: Teltasync, + config_entry: TeltonikaConfigEntry, + base_url: str, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Teltonika", + update_interval=SCAN_INTERVAL, + config_entry=config_entry, + ) + self.client = client + self.base_url = base_url + + async def _async_setup(self) -> None: + """Set up the coordinator - authenticate and fetch device info.""" + try: + await self.client.get_device_info() + system_info_response = await self.client.get_system_info() + except TeltonikaAuthenticationError as err: + raise ConfigEntryAuthFailed(f"Authentication failed: {err}") from err + except (ClientResponseError, ContentTypeError) as err: + if (isinstance(err, ClientResponseError) and err.status in (401, 403)) or ( + isinstance(err, ContentTypeError) and err.status == 403 + ): + raise ConfigEntryAuthFailed(f"Authentication failed: {err}") from err + raise ConfigEntryNotReady(f"Failed to connect to device: {err}") from err + except TeltonikaConnectionError as err: + raise ConfigEntryNotReady(f"Failed to connect to device: {err}") from err + + # Store device info for use by entities + self.device_info = DeviceInfo( + identifiers={(DOMAIN, system_info_response.mnf_info.serial)}, + name=system_info_response.static.device_name, + manufacturer="Teltonika", + model=system_info_response.static.model, + sw_version=system_info_response.static.fw_version, + serial_number=system_info_response.mnf_info.serial, + configuration_url=self.base_url, + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from Teltonika device.""" + modems = Modems(self.client.auth) + try: + # Get modems data using the teltasync library + modems_response = await modems.get_status() + except TeltonikaAuthenticationError as err: + raise ConfigEntryAuthFailed(f"Authentication failed: {err}") from err + except (ClientResponseError, ContentTypeError) as err: + if (isinstance(err, ClientResponseError) and err.status in (401, 403)) or ( + isinstance(err, ContentTypeError) and err.status == 403 + ): + raise ConfigEntryAuthFailed(f"Authentication failed: {err}") from err + raise UpdateFailed(f"Error communicating with device: {err}") from err + except TeltonikaConnectionError as err: + raise UpdateFailed(f"Error communicating with device: {err}") from err + + if not modems_response.success: + if modems_response.errors and any( + error.code in AUTH_ERROR_CODES for error in modems_response.errors + ): + raise ConfigEntryAuthFailed( + "Authentication failed: unauthorized access" + ) + + error_message = ( + modems_response.errors[0].error + if modems_response.errors + else "Unknown API error" + ) + raise UpdateFailed(f"Error communicating with device: {error_message}") + + # Return only modems which are online + modem_data: dict[str, Any] = {} + if modems_response.data: + modem_data.update( + { + modem.id: modem + for modem in modems_response.data + if Modems.is_online(modem) + } + ) + + return modem_data diff --git a/homeassistant/components/teltonika/manifest.json b/homeassistant/components/teltonika/manifest.json new file mode 100644 index 00000000000000..e6359073e70373 --- /dev/null +++ b/homeassistant/components/teltonika/manifest.json @@ -0,0 +1,19 @@ +{ + "domain": "teltonika", + "name": "Teltonika", + "codeowners": ["@karlbeecken"], + "config_flow": true, + "dhcp": [ + { + "macaddress": "209727*" + }, + { + "macaddress": "001E42*" + } + ], + "documentation": "https://www.home-assistant.io/integrations/teltonika", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "silver", + "requirements": ["teltasync==0.2.0"] +} diff --git a/homeassistant/components/teltonika/quality_scale.yaml b/homeassistant/components/teltonika/quality_scale.yaml new file mode 100644 index 00000000000000..8ac4004ef8e91d --- /dev/null +++ b/homeassistant/components/teltonika/quality_scale.yaml @@ -0,0 +1,70 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No custom actions registered. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No custom actions registered. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: No custom events registered. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: No custom actions registered. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options flow + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + 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: todo + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/teltonika/sensor.py b/homeassistant/components/teltonika/sensor.py new file mode 100644 index 00000000000000..623d73c987b7ed --- /dev/null +++ b/homeassistant/components/teltonika/sensor.py @@ -0,0 +1,187 @@ +"""Teltonika sensor platform.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from teltasync.modems import ModemStatus + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + SIGNAL_STRENGTH_DECIBELS, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + 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 homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import TeltonikaConfigEntry, TeltonikaDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class TeltonikaSensorEntityDescription(SensorEntityDescription): + """Describes Teltonika sensor entity.""" + + value_fn: Callable[[ModemStatus], StateType] + + +SENSOR_DESCRIPTIONS: tuple[TeltonikaSensorEntityDescription, ...] = ( + TeltonikaSensorEntityDescription( + key="rssi", + translation_key="rssi", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + suggested_display_precision=0, + value_fn=lambda modem: modem.rssi, + ), + TeltonikaSensorEntityDescription( + key="rsrp", + translation_key="rsrp", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + suggested_display_precision=0, + value_fn=lambda modem: modem.rsrp, + ), + TeltonikaSensorEntityDescription( + key="rsrq", + translation_key="rsrq", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + suggested_display_precision=0, + value_fn=lambda modem: modem.rsrq, + ), + TeltonikaSensorEntityDescription( + key="sinr", + translation_key="sinr", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + suggested_display_precision=0, + value_fn=lambda modem: modem.sinr, + ), + TeltonikaSensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=0, + value_fn=lambda modem: modem.temperature, + ), + TeltonikaSensorEntityDescription( + key="operator", + translation_key="operator", + value_fn=lambda modem: modem.operator, + ), + TeltonikaSensorEntityDescription( + key="connection_type", + translation_key="connection_type", + value_fn=lambda modem: modem.conntype, + ), + TeltonikaSensorEntityDescription( + key="band", + translation_key="band", + value_fn=lambda modem: modem.band, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TeltonikaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Teltonika sensor platform.""" + coordinator = entry.runtime_data + + # Track known modems to detect new ones + known_modems: set[str] = set() + + @callback + def _async_add_new_modems() -> None: + """Add sensors for newly discovered modems.""" + current_modems = set(coordinator.data.keys()) + new_modems = current_modems - known_modems + + if new_modems: + entities = [ + TeltonikaSensorEntity( + coordinator, + coordinator.device_info, + description, + modem_id, + coordinator.data[modem_id], + ) + for modem_id in new_modems + for description in SENSOR_DESCRIPTIONS + ] + async_add_entities(entities) + known_modems.update(new_modems) + + # Add sensors for initial modems + _async_add_new_modems() + + # Listen for new modems + entry.async_on_unload(coordinator.async_add_listener(_async_add_new_modems)) + + +class TeltonikaSensorEntity( + CoordinatorEntity[TeltonikaDataUpdateCoordinator], SensorEntity +): + """Teltonika sensor entity.""" + + _attr_has_entity_name = True + entity_description: TeltonikaSensorEntityDescription + + def __init__( + self, + coordinator: TeltonikaDataUpdateCoordinator, + device_info: DeviceInfo, + description: TeltonikaSensorEntityDescription, + modem_id: str, + modem: ModemStatus, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._modem_id = modem_id + self._attr_device_info = device_info + + # Create unique ID using entry unique identifier, modem ID, and sensor type + assert coordinator.config_entry is not None + entry_unique_id = ( + coordinator.config_entry.unique_id or coordinator.config_entry.entry_id + ) + self._attr_unique_id = f"{entry_unique_id}_{modem_id}_{description.key}" + + # Use translation key for proper naming + modem_name = modem.name or f"Modem {modem_id}" + self._modem_name = modem_name + self._attr_translation_key = description.translation_key + self._attr_translation_placeholders = {"modem_name": modem_name} + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self._modem_id in self.coordinator.data + + @property + def native_value(self) -> StateType: + """Handle updated data from the coordinator.""" + return self.entity_description.value_fn(self.coordinator.data[self._modem_id]) diff --git a/homeassistant/components/teltonika/strings.json b/homeassistant/components/teltonika/strings.json new file mode 100644 index 00000000000000..f775e620035c84 --- /dev/null +++ b/homeassistant/components/teltonika/strings.json @@ -0,0 +1,81 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "wrong_account": "The device does not match the existing configuration." + }, + "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": { + "dhcp_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "The password to authenticate with the device.", + "username": "The username to authenticate with the device." + }, + "description": "A Teltonika device ({name}) was discovered at {host}. Enter the credentials to add it to Home Assistant.", + "title": "Discovered Teltonika device" + }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "[%key:component::teltonika::config::step::dhcp_confirm::data_description::password%]", + "username": "[%key:component::teltonika::config::step::dhcp_confirm::data_description::username%]" + }, + "description": "Update the credentials for {name}. The current host is {host}.", + "title": "Authentication failed for {name}" + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of your Teltonika device.", + "password": "[%key:component::teltonika::config::step::dhcp_confirm::data_description::password%]", + "username": "[%key:component::teltonika::config::step::dhcp_confirm::data_description::username%]", + "verify_ssl": "Whether to validate the SSL certificate when using HTTPS. Most Teltonika devices use self-signed certificates, so you will need to disable this option unless you have installed a valid certificate on your device." + }, + "description": "Enter the connection details for your Teltonika device.", + "title": "Set up Teltonika device" + } + } + }, + "entity": { + "sensor": { + "band": { + "name": "{modem_name} Band" + }, + "connection_type": { + "name": "{modem_name} Connection type" + }, + "operator": { + "name": "{modem_name} Operator" + }, + "rsrp": { + "name": "{modem_name} RSRP" + }, + "rsrq": { + "name": "{modem_name} RSRQ" + }, + "rssi": { + "name": "{modem_name} RSSI" + }, + "sinr": { + "name": "{modem_name} SINR" + } + } + } +} diff --git a/homeassistant/components/teltonika/util.py b/homeassistant/components/teltonika/util.py new file mode 100644 index 00000000000000..54cc0c4fedf1f3 --- /dev/null +++ b/homeassistant/components/teltonika/util.py @@ -0,0 +1,39 @@ +"""Utility helpers for the Teltonika integration.""" + +from __future__ import annotations + +from yarl import URL + + +def normalize_url(host: str) -> str: + """Normalize host input to a base URL without path. + + Returns just the scheme://host part, without /api. + Ensures the URL has a scheme (defaults to HTTPS). + """ + host_input = host.strip().rstrip("/") + + # Parse or construct URL + if host_input.startswith(("http://", "https://")): + url = URL(host_input) + else: + # handle as scheme-relative URL and add HTTPS scheme by default + url = URL(f"//{host_input}").with_scheme("https") + + # Return base URL without path, only including scheme, host and port + return str(url.origin()) + + +def get_url_variants(host: str) -> list[str]: + """Get URL variants to try during setup (HTTPS first, then HTTP fallback).""" + normalized = normalize_url(host) + url = URL(normalized) + + # If user specified a scheme, only try that + if host.strip().startswith(("http://", "https://")): + return [normalized] + + # Otherwise try HTTPS first, then HTTP + https_url = str(url.with_scheme("https")) + http_url = str(url.with_scheme("http")) + return [https_url, http_url] diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 35c629c8af3592..c1a136a29ef0ac 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -4,7 +4,6 @@ import asyncio from collections.abc import Coroutine -from functools import partial import logging from typing import Any @@ -13,7 +12,10 @@ DOMAIN as AUTOMATION_DOMAIN, NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG, ) -from homeassistant.components.labs import async_listen as async_labs_listen +from homeassistant.components.labs import ( + EventLabsUpdatedData, + async_subscribe_preview_feature, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE_ID, @@ -22,7 +24,7 @@ CONF_UNIQUE_ID, SERVICE_RELOAD, ) -from homeassistant.core import Event, HomeAssistant, ServiceCall, callback +from homeassistant.core import Event, HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryError, HomeAssistantError from homeassistant.helpers import discovery, issue_registry as ir from homeassistant.helpers.device import ( @@ -99,18 +101,19 @@ async def _reload_config(call: Event | ServiceCall) -> None: async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, _reload_config) - @callback - def new_triggers_conditions_listener() -> None: + async def _handle_new_triggers_conditions( + _event_data: EventLabsUpdatedData, + ) -> None: """Handle new_triggers_conditions flag change.""" hass.async_create_task( _reload_config(ServiceCall(hass, DOMAIN, SERVICE_RELOAD)) ) - async_labs_listen( + async_subscribe_preview_feature( hass, AUTOMATION_DOMAIN, NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG, - new_triggers_conditions_listener, + _handle_new_triggers_conditions, ) return True @@ -139,12 +142,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, (entry.options["template_type"],) ) + async def _handle_entry_reload(_event_data: EventLabsUpdatedData) -> None: + hass.config_entries.async_schedule_reload(entry.entry_id) + entry.async_on_unload( - async_labs_listen( + async_subscribe_preview_feature( hass, AUTOMATION_DOMAIN, NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG, - partial(hass.config_entries.async_schedule_reload, entry.entry_id), + _handle_entry_reload, ) ) diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index eecbd1a38f1c7a..cc261ce32888e4 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -9,27 +9,27 @@ import voluptuous as vol from homeassistant.components.alarm_control_panel import ( - DOMAIN as DOMAIN_ALARM_CONTROL_PANEL, + DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, ) -from homeassistant.components.binary_sensor import DOMAIN as DOMAIN_BINARY_SENSOR +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.blueprint import ( is_blueprint_instance_config, schemas as blueprint_schemas, ) -from homeassistant.components.button import DOMAIN as DOMAIN_BUTTON -from homeassistant.components.cover import DOMAIN as DOMAIN_COVER -from homeassistant.components.event import DOMAIN as DOMAIN_EVENT -from homeassistant.components.fan import DOMAIN as DOMAIN_FAN -from homeassistant.components.image import DOMAIN as DOMAIN_IMAGE -from homeassistant.components.light import DOMAIN as DOMAIN_LIGHT -from homeassistant.components.lock import DOMAIN as DOMAIN_LOCK -from homeassistant.components.number import DOMAIN as DOMAIN_NUMBER -from homeassistant.components.select import DOMAIN as DOMAIN_SELECT -from homeassistant.components.sensor import DOMAIN as DOMAIN_SENSOR -from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH -from homeassistant.components.update import DOMAIN as DOMAIN_UPDATE -from homeassistant.components.vacuum import DOMAIN as DOMAIN_VACUUM -from homeassistant.components.weather import DOMAIN as DOMAIN_WEATHER +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN +from homeassistant.components.cover import DOMAIN as COVER_DOMAIN +from homeassistant.components.event import DOMAIN as EVENT_DOMAIN +from homeassistant.components.fan import DOMAIN as FAN_DOMAIN +from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN +from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN +from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.config import async_log_schema_error, config_without_domain from homeassistant.const import ( CONF_ACTION, @@ -86,8 +86,8 @@ def validate_binary_sensor_auto_off_has_trigger(obj: dict) -> dict: """Validate that binary sensors with auto_off have triggers.""" - if CONF_TRIGGERS not in obj and DOMAIN_BINARY_SENSOR in obj: - binary_sensors: list[ConfigType] = obj[DOMAIN_BINARY_SENSOR] + if CONF_TRIGGERS not in obj and BINARY_SENSOR_DOMAIN in obj: + binary_sensors: list[ConfigType] = obj[BINARY_SENSOR_DOMAIN] for binary_sensor in binary_sensors: if binary_sensor_platform.CONF_AUTO_OFF not in binary_sensor: continue @@ -192,53 +192,53 @@ def _backward_compat_schema(value: Any | None) -> Any: vol.Optional(CONF_TRIGGERS): cv.TRIGGER_SCHEMA, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA, - vol.Optional(DOMAIN_ALARM_CONTROL_PANEL): vol.All( + vol.Optional(ALARM_CONTROL_PANEL_DOMAIN): vol.All( cv.ensure_list, [alarm_control_panel_platform.ALARM_CONTROL_PANEL_YAML_SCHEMA], ), - vol.Optional(DOMAIN_BINARY_SENSOR): vol.All( + vol.Optional(BINARY_SENSOR_DOMAIN): vol.All( cv.ensure_list, [binary_sensor_platform.BINARY_SENSOR_YAML_SCHEMA] ), - vol.Optional(DOMAIN_BUTTON): vol.All( + vol.Optional(BUTTON_DOMAIN): vol.All( cv.ensure_list, [button_platform.BUTTON_YAML_SCHEMA] ), - vol.Optional(DOMAIN_COVER): vol.All( + vol.Optional(COVER_DOMAIN): vol.All( cv.ensure_list, [cover_platform.COVER_YAML_SCHEMA] ), - vol.Optional(DOMAIN_EVENT): vol.All( + vol.Optional(EVENT_DOMAIN): vol.All( cv.ensure_list, [event_platform.EVENT_YAML_SCHEMA] ), - vol.Optional(DOMAIN_FAN): vol.All( + vol.Optional(FAN_DOMAIN): vol.All( cv.ensure_list, [fan_platform.FAN_YAML_SCHEMA] ), - vol.Optional(DOMAIN_IMAGE): vol.All( + vol.Optional(IMAGE_DOMAIN): vol.All( cv.ensure_list, [image_platform.IMAGE_YAML_SCHEMA] ), - vol.Optional(DOMAIN_LIGHT): vol.All( + vol.Optional(LIGHT_DOMAIN): vol.All( cv.ensure_list, [light_platform.LIGHT_YAML_SCHEMA] ), - vol.Optional(DOMAIN_LOCK): vol.All( + vol.Optional(LOCK_DOMAIN): vol.All( cv.ensure_list, [lock_platform.LOCK_YAML_SCHEMA] ), - vol.Optional(DOMAIN_NUMBER): vol.All( + vol.Optional(NUMBER_DOMAIN): vol.All( cv.ensure_list, [number_platform.NUMBER_YAML_SCHEMA] ), - vol.Optional(DOMAIN_SELECT): vol.All( + vol.Optional(SELECT_DOMAIN): vol.All( cv.ensure_list, [select_platform.SELECT_YAML_SCHEMA] ), - vol.Optional(DOMAIN_SENSOR): vol.All( + vol.Optional(SENSOR_DOMAIN): vol.All( cv.ensure_list, [sensor_platform.SENSOR_YAML_SCHEMA] ), - vol.Optional(DOMAIN_SWITCH): vol.All( + vol.Optional(SWITCH_DOMAIN): vol.All( cv.ensure_list, [switch_platform.SWITCH_YAML_SCHEMA] ), - vol.Optional(DOMAIN_UPDATE): vol.All( + vol.Optional(UPDATE_DOMAIN): vol.All( cv.ensure_list, [update_platform.UPDATE_YAML_SCHEMA] ), - vol.Optional(DOMAIN_VACUUM): vol.All( + vol.Optional(VACUUM_DOMAIN): vol.All( cv.ensure_list, [vacuum_platform.VACUUM_YAML_SCHEMA] ), - vol.Optional(DOMAIN_WEATHER): vol.All( + vol.Optional(WEATHER_DOMAIN): vol.All( cv.ensure_list, [ vol.Any( @@ -250,7 +250,7 @@ def _backward_compat_schema(value: Any | None) -> Any: }, ), ensure_domains_do_not_have_trigger_or_action( - DOMAIN_BUTTON, + BUTTON_DOMAIN, ), validate_binary_sensor_auto_off_has_trigger, ) @@ -382,12 +382,12 @@ async def async_validate_config(hass: HomeAssistant, config: ConfigType) -> Conf for old_key, new_key, legacy_fields in ( ( CONF_SENSORS, - DOMAIN_SENSOR, + SENSOR_DOMAIN, sensor_platform.LEGACY_FIELDS, ), ( CONF_BINARY_SENSORS, - DOMAIN_BINARY_SENSOR, + BINARY_SENSOR_DOMAIN, binary_sensor_platform.LEGACY_FIELDS, ), ): diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index 1b3ac858c4cde2..a3184c4ba9818d 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -257,6 +257,9 @@ def _validate_state( ) -> StateType | date | datetime | Decimal | None: """Validate the state.""" if self._numeric_state_expected: + if not isinstance(result, bool) and isinstance(result, (int, float)): + return result + return template_validators.number(self, CONF_STATE)(result) if result is None or self.device_class not in ( diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 953a5a89542433..a45c5e5e66a226 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -29,7 +29,7 @@ ) from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, async_generate_entity_id from homeassistant.helpers.event import ( TrackTemplate, TrackTemplateResult, @@ -264,16 +264,30 @@ def referenced_blueprint(self) -> str | None: return None return cast(str, self._blueprint_inputs[CONF_USE_BLUEPRINT][CONF_PATH]) + def _get_this_variable(self) -> TemplateStateFromEntityId: + """Create a this variable for the entity.""" + entity_id = self.entity_id + if self._preview_callback: + # During config flow, the registry entry and entity_id will be None. In this scenario, + # a temporary entity_id is created. + # During option flow, the preview entity_id will be None, however the registry entry + # will contain the target entity_id. + if self.registry_entry: + entity_id = self.registry_entry.entity_id + else: + entity_id = async_generate_entity_id( + self._entity_id_format, self._attr_name or "preview", hass=self.hass + ) + + return TemplateStateFromEntityId(self.hass, entity_id) + def _render_script_variables(self) -> dict[str, Any]: """Render configured variables.""" if isinstance(self._run_variables, dict): return self._run_variables return self._run_variables.async_render( - self.hass, - { - "this": TemplateStateFromEntityId(self.hass, self.entity_id), - }, + self.hass, {"this": self._get_this_variable()} ) def setup_state_template( @@ -451,7 +465,7 @@ def _async_template_startup( has_availability_template = False variables = { - "this": TemplateStateFromEntityId(self.hass, self.entity_id), + "this": self._get_this_variable(), **self._render_script_variables(), } diff --git a/homeassistant/components/template/update.py b/homeassistant/components/template/update.py index 7b03d606aaf0ea..b3231191a34cf2 100644 --- a/homeassistant/components/template/update.py +++ b/homeassistant/components/template/update.py @@ -266,7 +266,7 @@ def entity_picture(self) -> str | None: # The default picture for update entities would use `self.platform.platform_name` in # place of `template`. This does not work when creating an entity preview because # the platform does not exist for that entity, therefore this is hardcoded as `template`. - return "https://brands.home-assistant.io/_/template/icon.png" + return "/api/brands/integration/template/icon.png" return self._attr_entity_picture diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py index f875372b8aec71..15818c4d0eee05 100644 --- a/homeassistant/components/tesla_fleet/coordinator.py +++ b/homeassistant/components/tesla_fleet/coordinator.py @@ -25,7 +25,9 @@ if TYPE_CHECKING: from . import TeslaFleetConfigEntry -from .const import ENERGY_HISTORY_FIELDS, LOGGER, TeslaFleetState +from homeassistant.util import dt as dt_util + +from .const import DOMAIN, ENERGY_HISTORY_FIELDS, LOGGER, TeslaFleetState VEHICLE_INTERVAL_SECONDS = 600 VEHICLE_INTERVAL = timedelta(seconds=VEHICLE_INTERVAL_SECONDS) @@ -193,9 +195,22 @@ async def _async_update_data(self) -> dict[str, Any]: except TeslaFleetError as e: raise UpdateFailed(e.message) from e + if not isinstance(data, dict): + LOGGER.debug( + "%s got unexpected live status response type: %s", + self.name, + type(data).__name__, + ) + return self.data + # Convert Wall Connectors from array to dict + wall_connectors = data.get("wall_connectors") + if not isinstance(wall_connectors, list): + wall_connectors = [] data["wall_connectors"] = { - wc["din"]: wc for wc in (data.get("wall_connectors") or []) + wc["din"]: wc + for wc in wall_connectors + if isinstance(wc, dict) and "din" in wc } self.updated_once = True @@ -258,12 +273,22 @@ async def _async_update_data(self) -> dict[str, Any]: raise UpdateFailed(e.message) from e self.updated_once = True - if not data or not isinstance(data.get("time_series"), list): - raise UpdateFailed("Received invalid data") + if ( + not data + or not isinstance((time_series := data.get("time_series")), list) + or not time_series + or not isinstance((first_period := time_series[0]), dict) + or not isinstance((timestamp := first_period.get("timestamp")), str) + or (period_start := dt_util.parse_datetime(timestamp)) is None + ): + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="invalid_data", + ) # Add all time periods together - output = dict.fromkeys(ENERGY_HISTORY_FIELDS, None) - for period in data.get("time_series", []): + output: dict[str, Any] = dict.fromkeys(ENERGY_HISTORY_FIELDS, None) + for period in time_series: for key in ENERGY_HISTORY_FIELDS: if key in period: if output[key] is None: @@ -271,6 +296,8 @@ async def _async_update_data(self) -> dict[str, Any]: else: output[key] += period[key] + output["_period_start"] = period_start + return output diff --git a/homeassistant/components/tesla_fleet/sensor.py b/homeassistant/components/tesla_fleet/sensor.py index 7d2fe82999698f..fefb03a97ba6b1 100644 --- a/homeassistant/components/tesla_fleet/sensor.py +++ b/homeassistant/components/tesla_fleet/sensor.py @@ -47,6 +47,9 @@ PARALLEL_UPDATES = 0 +CHARGE_ENERGY_RESET_KEYS = frozenset({"charge_state_charge_energy_added"}) +CHARGE_ENERGY_RESET_THRESHOLD = 1.0 # kWh + CHARGE_STATES = { "Starting": "starting", "Charging": "charging", @@ -88,7 +91,7 @@ class TeslaFleetSensorEntityDescription(SensorEntityDescription): ), TeslaFleetSensorEntityDescription( key="charge_state_charge_energy_added", - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, suggested_display_precision=1, @@ -424,7 +427,7 @@ class TeslaFleetTimeEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=2, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, entity_registry_enabled_default=( key.startswith("total") or key == "grid_energy_imported" ), @@ -497,6 +500,7 @@ class TeslaFleetVehicleSensorEntity(TeslaFleetVehicleEntity, RestoreSensor): """Base class for Tesla Fleet vehicle metric sensors.""" entity_description: TeslaFleetSensorEntityDescription + _previous_value: float | None = None def __init__( self, @@ -513,11 +517,30 @@ async def async_added_to_hass(self) -> None: if self.coordinator.data.get("state") != TeslaFleetState.ONLINE: if (sensor_data := await self.async_get_last_sensor_data()) is not None: self._attr_native_value = sensor_data.native_value + if isinstance(sensor_data.native_value, float | int): + self._previous_value = float(sensor_data.native_value) + + if ( + self.entity_description.key in CHARGE_ENERGY_RESET_KEYS + and (last_state := await self.async_get_last_state()) is not None + and (last_reset := last_state.attributes.get("last_reset")) is not None + ): + self._attr_last_reset = dt_util.parse_datetime(str(last_reset)) def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" if self.has: - self._attr_native_value = self.entity_description.value_fn(self._value) + new_value = self.entity_description.value_fn(self._value) + if self.entity_description.key in CHARGE_ENERGY_RESET_KEYS and isinstance( + new_value, float | int + ): + if self._previous_value is not None and ( + (new_value == 0 and self._previous_value != 0) + or new_value < self._previous_value - CHARGE_ENERGY_RESET_THRESHOLD + ): + self._attr_last_reset = dt_util.utcnow() + self._previous_value = float(new_value) + self._attr_native_value = new_value else: self._attr_native_value = None @@ -584,6 +607,7 @@ def __init__( def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" self._attr_native_value = self._value + self._attr_last_reset = self.coordinator.data.get("_period_start") class TeslaFleetWallConnectorSensorEntity(TeslaFleetWallConnectorEntity, SensorEntity): diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index 5b4399d13f7fd2..14927768331cc8 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -612,6 +612,9 @@ "invalid_cop_temp": { "message": "Cabin overheat protection does not support that temperature." }, + "invalid_data": { + "message": "Received invalid data" + }, "missing_scope_energy_cmds": { "message": "Missing energy commands scope." }, diff --git a/homeassistant/components/tesla_wall_connector/__init__.py b/homeassistant/components/tesla_wall_connector/__init__.py index 01c657fbcaa985..f6809c4f416ce4 100644 --- a/homeassistant/components/tesla_wall_connector/__init__.py +++ b/homeassistant/components/tesla_wall_connector/__init__.py @@ -2,35 +2,20 @@ from __future__ import annotations -from dataclasses import dataclass -from datetime import timedelta -import logging - from tesla_wall_connector import WallConnector -from tesla_wall_connector.exceptions import ( - WallConnectorConnectionError, - WallConnectorConnectionTimeoutError, - WallConnectorError, -) +from tesla_wall_connector.exceptions import WallConnectorError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL, Platform +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 homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ( - DEFAULT_SCAN_INTERVAL, - DOMAIN, - WALLCONNECTOR_DATA_LIFETIME, - WALLCONNECTOR_DATA_VITALS, -) +from .const import DOMAIN +from .coordinator import WallConnectorCoordinator, WallConnectorData, get_poll_interval PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Tesla Wall Connector from a config entry.""" @@ -44,39 +29,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except WallConnectorError as ex: raise ConfigEntryNotReady from ex - async def async_update_data(): - """Fetch new data from the Wall Connector.""" - try: - vitals = await wall_connector.async_get_vitals() - lifetime = await wall_connector.async_get_lifetime() - except WallConnectorConnectionTimeoutError as ex: - raise UpdateFailed( - f"Could not fetch data from Tesla WallConnector at {hostname}: Timeout" - ) from ex - except WallConnectorConnectionError as ex: - raise UpdateFailed( - f"Could not fetch data from Tesla WallConnector at {hostname}: Cannot" - " connect" - ) from ex - except WallConnectorError as ex: - raise UpdateFailed( - f"Could not fetch data from Tesla WallConnector at {hostname}: {ex}" - ) from ex - - return { - WALLCONNECTOR_DATA_VITALS: vitals, - WALLCONNECTOR_DATA_LIFETIME: lifetime, - } - - coordinator: DataUpdateCoordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name="tesla-wallconnector", - update_interval=get_poll_interval(entry), - update_method=async_update_data, - ) - + coordinator = WallConnectorCoordinator(hass, entry, hostname, wall_connector) await coordinator.async_config_entry_first_refresh() hass.data[DOMAIN][entry.entry_id] = WallConnectorData( @@ -95,13 +48,6 @@ async def async_update_data(): return True -def get_poll_interval(entry: ConfigEntry) -> timedelta: - """Get the poll interval from config.""" - return timedelta( - seconds=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - ) - - async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" wall_connector_data: WallConnectorData = hass.data[DOMAIN][entry.entry_id] @@ -114,15 +60,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -@dataclass -class WallConnectorData: - """Data for the Tesla Wall Connector integration.""" - - wall_connector_client: WallConnector - update_coordinator: DataUpdateCoordinator - hostname: str - part_number: str - firmware_version: str - serial_number: str diff --git a/homeassistant/components/tesla_wall_connector/binary_sensor.py b/homeassistant/components/tesla_wall_connector/binary_sensor.py index 6d60162412ef7b..a1781c8d8fb24f 100644 --- a/homeassistant/components/tesla_wall_connector/binary_sensor.py +++ b/homeassistant/components/tesla_wall_connector/binary_sensor.py @@ -13,8 +13,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import WallConnectorData from .const import DOMAIN, WALLCONNECTOR_DATA_VITALS +from .coordinator import WallConnectorData from .entity import WallConnectorEntity, WallConnectorLambdaValueGetterMixin _LOGGER = logging.getLogger(__name__) @@ -64,6 +64,8 @@ async def async_setup_entry( class WallConnectorBinarySensorEntity(WallConnectorEntity, BinarySensorEntity): """Wall Connector Sensor Entity.""" + entity_description: WallConnectorBinarySensorDescription + def __init__( self, wall_connectord_data: WallConnectorData, @@ -74,7 +76,7 @@ def __init__( super().__init__(wall_connectord_data) @property - def is_on(self): + def is_on(self) -> bool: """Return the state of the sensor.""" return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/tesla_wall_connector/coordinator.py b/homeassistant/components/tesla_wall_connector/coordinator.py new file mode 100644 index 00000000000000..bc43a0581dcfb0 --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/coordinator.py @@ -0,0 +1,96 @@ +"""DataUpdateCoordinator for the Tesla Wall Connector integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging + +from tesla_wall_connector import WallConnector +from tesla_wall_connector.exceptions import ( + WallConnectorConnectionError, + WallConnectorConnectionTimeoutError, + WallConnectorError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + DEFAULT_SCAN_INTERVAL, + WALLCONNECTOR_DATA_LIFETIME, + WALLCONNECTOR_DATA_VITALS, +) + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class WallConnectorData: + """Data for the Tesla Wall Connector integration.""" + + wall_connector_client: WallConnector + update_coordinator: WallConnectorCoordinator + hostname: str + part_number: str + firmware_version: str + serial_number: str + + +def get_poll_interval(entry: ConfigEntry) -> timedelta: + """Get the poll interval from config.""" + return timedelta( + seconds=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + ) + + +class WallConnectorCoordinator(DataUpdateCoordinator[dict]): + """Class to manage fetching Tesla Wall Connector data.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + hostname: str, + wall_connector: WallConnector, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name="tesla-wallconnector", + update_interval=get_poll_interval(entry), + ) + self._hostname = hostname + self._wall_connector = wall_connector + + async def _async_update_data(self) -> dict: + """Fetch new data from the Wall Connector.""" + try: + vitals = await self._wall_connector.async_get_vitals() + lifetime = await self._wall_connector.async_get_lifetime() + except WallConnectorConnectionTimeoutError as ex: + raise UpdateFailed( + f"Could not fetch data from Tesla WallConnector at {self._hostname}:" + " Timeout" + ) from ex + except WallConnectorConnectionError as ex: + raise UpdateFailed( + f"Could not fetch data from Tesla WallConnector at {self._hostname}:" + " Cannot connect" + ) from ex + except WallConnectorError as ex: + raise UpdateFailed( + f"Could not fetch data from Tesla WallConnector at {self._hostname}:" + f" {ex}" + ) from ex + + return { + WALLCONNECTOR_DATA_VITALS: vitals, + WALLCONNECTOR_DATA_LIFETIME: lifetime, + } diff --git a/homeassistant/components/tesla_wall_connector/entity.py b/homeassistant/components/tesla_wall_connector/entity.py index ea08a00e791d5c..1dea2d0baa108c 100644 --- a/homeassistant/components/tesla_wall_connector/entity.py +++ b/homeassistant/components/tesla_wall_connector/entity.py @@ -9,8 +9,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import WallConnectorData from .const import DOMAIN, WALLCONNECTOR_DEVICE_NAME +from .coordinator import WallConnectorCoordinator, WallConnectorData @dataclass(frozen=True) @@ -25,7 +25,7 @@ def _get_unique_id(serial_number: str, key: str) -> str: return f"{serial_number}-{key}" -class WallConnectorEntity(CoordinatorEntity): +class WallConnectorEntity(CoordinatorEntity[WallConnectorCoordinator]): """Base class for Wall Connector entities.""" _attr_has_entity_name = True diff --git a/homeassistant/components/tesla_wall_connector/manifest.json b/homeassistant/components/tesla_wall_connector/manifest.json index e01e6e5a5d8237..d008d99f1c16a9 100644 --- a/homeassistant/components/tesla_wall_connector/manifest.json +++ b/homeassistant/components/tesla_wall_connector/manifest.json @@ -18,6 +18,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/tesla_wall_connector", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["tesla_wall_connector"], "requirements": ["tesla-wall-connector==1.1.0"] diff --git a/homeassistant/components/tesla_wall_connector/sensor.py b/homeassistant/components/tesla_wall_connector/sensor.py index 290f4948ccc540..8a57bb7c2f48bd 100644 --- a/homeassistant/components/tesla_wall_connector/sensor.py +++ b/homeassistant/components/tesla_wall_connector/sensor.py @@ -22,8 +22,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import WallConnectorData from .const import DOMAIN, WALLCONNECTOR_DATA_LIFETIME, WALLCONNECTOR_DATA_VITALS +from .coordinator import WallConnectorData from .entity import WallConnectorEntity, WallConnectorLambdaValueGetterMixin _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index a750a9262b9abc..2c00094b40b0d5 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -3,7 +3,7 @@ import asyncio from collections.abc import Callable from functools import partial -from typing import Final +from typing import Any, Final, cast from aiohttp import ClientError, ClientResponseError from tesla_fleet_api.const import Scope @@ -22,7 +22,7 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -39,6 +39,7 @@ TeslemetryEnergyHistoryCoordinator, TeslemetryEnergySiteInfoCoordinator, TeslemetryEnergySiteLiveCoordinator, + TeslemetryMetadataCoordinator, TeslemetryVehicleDataCoordinator, ) from .helpers import async_update_device_sw_version, flatten @@ -48,6 +49,7 @@ PLATFORMS: Final = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.CALENDAR, Platform.CLIMATE, Platform.COVER, Platform.DEVICE_TRACKER, @@ -105,7 +107,62 @@ async def _get_access_token(oauth_session: OAuth2Session) -> str: translation_domain=DOMAIN, translation_key="not_ready_connection_error", ) from err - return oauth_session.token[CONF_ACCESS_TOKEN] + return cast(str, oauth_session.token[CONF_ACCESS_TOKEN]) + + +def _get_subscribed_ids_from_metadata( + data: dict[str, Any], +) -> tuple[set[str], set[str]]: + """Return metadata device IDs that have an active subscription.""" + subscribed_vins = { + vin for vin, info in data["vehicles"].items() if info.get("access") + } + subscribed_site_ids = { + site_id for site_id, info in data["energy_sites"].items() if info.get("access") + } + + return subscribed_vins, subscribed_site_ids + + +def _setup_dynamic_discovery( + hass: HomeAssistant, + entry: TeslemetryConfigEntry, + metadata_coordinator: TeslemetryMetadataCoordinator, + known_vins: set[str], + known_site_ids: set[str], +) -> None: + """Set up dynamic device discovery via reload when subscriptions change.""" + + @callback + def _handle_metadata_update() -> None: + """Handle metadata coordinator update - detect subscription changes.""" + data = metadata_coordinator.data + if not data: + return + + current_vins, current_site_ids = _get_subscribed_ids_from_metadata(data) + + added_vins = current_vins - known_vins + removed_vins = known_vins - current_vins + added_sites = current_site_ids - known_site_ids + removed_sites = known_site_ids - current_site_ids + + if added_vins or removed_vins or added_sites or removed_sites: + LOGGER.info( + "Tesla subscription changes detected " + "(added vehicles: %s, removed vehicles: %s, " + "added energy sites: %s, removed energy sites: %s), " + "reloading integration", + added_vins or "none", + removed_vins or "none", + added_sites or "none", + removed_sites or "none", + ) + hass.config_entries.async_schedule_reload(entry.entry_id) + + entry.async_on_unload( + metadata_coordinator.async_add_listener(_handle_metadata_update) + ) async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -> bool: @@ -158,6 +215,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - scopes = calls[0]["scopes"] region = calls[0]["region"] vehicle_metadata = calls[0]["vehicles"] + energy_site_metadata = calls[0]["energy_sites"] products = calls[1]["response"] device_registry = dr.async_get(hass) @@ -166,21 +224,36 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - vehicles: list[TeslemetryVehicleData] = [] energysites: list[TeslemetryEnergyData] = [] - # Create the stream + # Create the stream (created lazily when first vehicle is found) stream: TeslemetryStream | None = None # Remember each device identifier we create current_devices: set[tuple[str, str]] = set() + # Track known devices for dynamic discovery (based on metadata access state) + known_vins, known_site_ids = _get_subscribed_ids_from_metadata(calls[0]) + for product in products: if ( "vin" in product and vehicle_metadata.get(product["vin"], {}).get("access") and Scope.VEHICLE_DEVICE_DATA in scopes ): + vin = product["vin"] + current_devices.add((DOMAIN, vin)) + + # Create stream if required (for first vehicle) + if not stream: + stream = TeslemetryStream( + session, + access_token, + server=f"{region.lower()}.teslemetry.com", + parse_timestamp=True, + manual=True, + ) + # Remove the protobuff 'cached_data' that we do not use to save memory product.pop("cached_data", None) - vin = product["vin"] vehicle = teslemetry.vehicles.create(vin) coordinator = TeslemetryVehicleDataCoordinator( hass, entry, vehicle, product @@ -196,17 +269,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - serial_number=vin, sw_version=firmware, ) - current_devices.add((DOMAIN, vin)) - # Create stream if required - if not stream: - stream = TeslemetryStream( - session, - access_token, - server=f"{region.lower()}.teslemetry.com", - parse_timestamp=True, - manual=True, - ) + poll = vehicle_metadata[vin].get("polling", False) entry.async_on_unload( stream.async_add_listener( @@ -215,7 +279,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - ) ) stream_vehicle = stream.get_vehicle(vin) - poll = vehicle_metadata[vin].get("polling", False) vehicles.append( TeslemetryVehicleData( @@ -226,13 +289,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - stream=stream, stream_vehicle=stream_vehicle, vin=vin, - firmware=firmware, + firmware=firmware or "Unknown", device=device, ) ) - elif "energy_site_id" in product and Scope.ENERGY_DEVICE_DATA in scopes: + elif ( + "energy_site_id" in product + and Scope.ENERGY_DEVICE_DATA in scopes + and energy_site_metadata.get(str(product["energy_site_id"]), {}).get( + "access" + ) + ): site_id = product["energy_site_id"] + powerwall = ( product["components"]["battery"] or product["components"]["solar"] ) @@ -244,6 +314,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - ) continue + current_devices.add((DOMAIN, str(site_id))) + if wall_connector: + current_devices |= { + (DOMAIN, c["din"]) for c in product["components"]["wall_connectors"] + } + energy_site = teslemetry.energySites.create(site_id) device = DeviceInfo( identifiers={(DOMAIN, str(site_id))}, @@ -252,13 +328,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - name=product.get("site_name", "Energy Site"), serial_number=str(site_id), ) - current_devices.add((DOMAIN, str(site_id))) - if wall_connector: - for connector in product["components"]["wall_connectors"]: - current_devices.add((DOMAIN, connector["din"])) - - # Check live status endpoint works before creating its coordinator + # For initial setup, raise auth errors properly try: live_status = (await energy_site.live_status())["response"] except InvalidToken as e: @@ -347,10 +418,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - remove_config_entry_id=entry.entry_id, ) - # Setup Platforms - entry.runtime_data = TeslemetryData(vehicles, energysites, scopes, stream) + metadata_coordinator = TeslemetryMetadataCoordinator(hass, entry, teslemetry) + + entry.runtime_data = TeslemetryData( + vehicles=vehicles, + energysites=energysites, + scopes=scopes, + stream=stream, + metadata_coordinator=metadata_coordinator, + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + _setup_dynamic_discovery( + hass, + entry, + metadata_coordinator, + known_vins, + known_site_ids, + ) + if stream: entry.async_on_unload(stream.close) entry.async_create_background_task(hass, stream.listen(), "Teslemetry Stream") @@ -397,10 +483,12 @@ async def async_migrate_entry( return True -def create_handle_vehicle_stream(vin: str, coordinator) -> Callable[[dict], None]: +def create_handle_vehicle_stream( + vin: str, coordinator: TeslemetryVehicleDataCoordinator +) -> Callable[[dict[str, Any]], None]: """Create a handle vehicle stream function.""" - def handle_vehicle_stream(data: dict) -> None: + def handle_vehicle_stream(data: dict[str, Any]) -> None: """Handle vehicle data from the stream.""" if "vehicle_data" in data: LOGGER.debug("Streaming received vehicle data from %s", vin) @@ -449,9 +537,8 @@ def async_setup_energy_device( async def async_setup_stream( hass: HomeAssistant, entry: TeslemetryConfigEntry, vehicle: TeslemetryVehicleData -): +) -> None: """Set up the stream for a vehicle.""" - await vehicle.stream_vehicle.get_config() entry.async_create_background_task( hass, diff --git a/homeassistant/components/teslemetry/calendar.py b/homeassistant/components/teslemetry/calendar.py new file mode 100644 index 00000000000000..71877344129877 --- /dev/null +++ b/homeassistant/components/teslemetry/calendar.py @@ -0,0 +1,282 @@ +"""Calendar platform for Teslemetry integration.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util + +from . import TeslemetryConfigEntry +from .entity import TeslemetryEnergyInfoEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TeslemetryConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Teslemetry Calendar platform from a config entry.""" + + entities_to_add: list[CalendarEntity] = [] + + entities_to_add.extend( + TeslemetryTariffSchedule(energy, "tariff_content_v2") + for energy in entry.runtime_data.energysites + if energy.info_coordinator.data.get("tariff_content_v2_seasons") + ) + + entities_to_add.extend( + TeslemetryTariffSchedule(energy, "tariff_content_v2_sell_tariff") + for energy in entry.runtime_data.energysites + if energy.info_coordinator.data.get("tariff_content_v2_sell_tariff_seasons") + ) + + async_add_entities(entities_to_add) + + +def _is_day_in_range(day_of_week: int, from_day: int, to_day: int) -> bool: + """Check if a day of week falls within a range, handling week crossing.""" + if from_day <= to_day: + return from_day <= day_of_week <= to_day + # Week crossing (e.g., Fri=4 to Mon=0) + return day_of_week >= from_day or day_of_week <= to_day + + +def _parse_period_times( + period_def: dict[str, Any], + base_day: datetime, +) -> tuple[datetime, datetime] | None: + """Parse a TOU period definition into start and end times. + + Returns None if the base_day's weekday doesn't match the period's day range. + For periods crossing midnight, end_time will be on the following day. + """ + # DaysOfWeek are from 0-6 (Monday-Sunday) + from_day = period_def.get("fromDayOfWeek", 0) + to_day = period_def.get("toDayOfWeek", 6) + + if not _is_day_in_range(base_day.weekday(), from_day, to_day): + return None + + # Hours are from 0-23, so 24 hours is 0-0 + from_hour = period_def.get("fromHour", 0) + to_hour = period_def.get("toHour", 0) + + # Minutes are from 0-59, so 60 minutes is 0-0 + from_minute = period_def.get("fromMinute", 0) + to_minute = period_def.get("toMinute", 0) + + start_time = base_day.replace( + hour=from_hour, minute=from_minute, second=0, microsecond=0 + ) + end_time = base_day.replace(hour=to_hour, minute=to_minute, second=0, microsecond=0) + + if end_time <= start_time: + end_time += timedelta(days=1) + + return start_time, end_time + + +def _build_event( + key_base: str, + season_name: str, + period_name: str, + price: float | None, + start_time: datetime, + end_time: datetime, +) -> CalendarEvent: + """Build a CalendarEvent for a tariff period.""" + price_str = f"{price:.2f}/kWh" if price is not None else "Unknown Price" + return CalendarEvent( + start=start_time, + end=end_time, + summary=f"{period_name.capitalize().replace('_', ' ')}: {price_str}", + description=( + f"Season: {season_name.capitalize()}\n" + f"Period: {period_name.capitalize().replace('_', ' ')}\n" + f"Price: {price_str}" + ), + uid=f"{key_base}_{season_name}_{period_name}_{start_time.isoformat()}", + ) + + +class TeslemetryTariffSchedule(TeslemetryEnergyInfoEntity, CalendarEntity): + """Energy Site Tariff Schedule Calendar.""" + + def __init__( + self, + data: Any, + key_base: str, + ) -> None: + """Initialize the tariff schedule calendar.""" + self.key_base: str = key_base + self.seasons: dict[str, dict[str, Any]] = {} + self.charges: dict[str, dict[str, Any]] = {} + super().__init__(data, key_base) + + @property + def event(self) -> CalendarEvent | None: + """Return the current active tariff event.""" + now = dt_util.now() + current_season_name = self._get_current_season(now) + + if not current_season_name or not self.seasons.get(current_season_name): + return None + + # Time of use (TOU) periods define the tariff schedule within a season + tou_periods = self.seasons[current_season_name].get("tou_periods", {}) + + for period_name, period_group in tou_periods.items(): + for period_def in period_group.get("periods", []): + result = _parse_period_times(period_def, now) + if result is None: + continue + + start_time, end_time = result + + # Check if now falls within this period + if not (start_time <= now < end_time): + # For cross-midnight periods, check yesterday's instance + start_time -= timedelta(days=1) + end_time -= timedelta(days=1) + if not (start_time <= now < end_time): + continue + + price = self._get_price_for_period(current_season_name, period_name) + return _build_event( + self.key_base, + current_season_name, + period_name, + price, + start_time, + end_time, + ) + + return None + + async def async_get_events( + self, + hass: HomeAssistant, + start_date: datetime, + end_date: datetime, + ) -> list[CalendarEvent]: + """Return calendar events (tariff periods) within a datetime range.""" + events: list[CalendarEvent] = [] + + start_date = dt_util.as_local(start_date) + end_date = dt_util.as_local(end_date) + + # Start one day earlier to catch TOU periods that cross midnight + # from the previous evening into the query range. + current_day = dt_util.start_of_local_day(start_date) - timedelta(days=1) + while current_day < end_date: + season_name = self._get_current_season(current_day) + if not season_name or not self.seasons.get(season_name): + current_day += timedelta(days=1) + continue + + tou_periods = self.seasons[season_name].get("tou_periods", {}) + + for period_name, period_group in tou_periods.items(): + for period_def in period_group.get("periods", []): + result = _parse_period_times(period_def, current_day) + if result is None: + continue + + start_time, end_time = result + + if start_time < end_date and end_time > start_date: + price = self._get_price_for_period(season_name, period_name) + events.append( + _build_event( + self.key_base, + season_name, + period_name, + price, + start_time, + end_time, + ) + ) + + current_day += timedelta(days=1) + + events.sort(key=lambda x: x.start) + return events + + def _get_current_season(self, date_to_check: datetime) -> str | None: + """Determine the active season for a given date.""" + local_date = dt_util.as_local(date_to_check) + year = local_date.year + + for season_name, season_data in self.seasons.items(): + if not season_data: + continue + + try: + from_month = season_data["fromMonth"] + from_day = season_data["fromDay"] + to_month = season_data["toMonth"] + to_day = season_data["toDay"] + + # Handle seasons that cross year boundaries + start_year = year + end_year = year + + # Season crosses year boundary (e.g., Oct-Mar) + if from_month > to_month or ( + from_month == to_month and from_day > to_day + ): + if local_date.month > from_month or ( + local_date.month == from_month and local_date.day >= from_day + ): + end_year = year + 1 + else: + start_year = year - 1 + + season_start = local_date.replace( + year=start_year, + month=from_month, + day=from_day, + hour=0, + minute=0, + second=0, + microsecond=0, + ) + season_end = local_date.replace( + year=end_year, + month=to_month, + day=to_day, + hour=0, + minute=0, + second=0, + microsecond=0, + ) + timedelta(days=1) + + if season_start <= local_date < season_end: + return season_name + except KeyError, ValueError: + continue + + return None + + def _get_price_for_period(self, season_name: str, period_name: str) -> float | None: + """Get the price for a specific season and period name.""" + try: + season_charges = self.charges.get(season_name, self.charges.get("ALL", {})) + rates = season_charges.get("rates", {}) + price = rates.get(period_name, rates.get("ALL")) + return float(price) if price is not None else None + except KeyError, ValueError, TypeError: + return None + + def _async_update_attrs(self) -> None: + """Update the Calendar attributes from coordinator data.""" + self.seasons = self.coordinator.data.get(f"{self.key_base}_seasons", {}) + self.charges = self.coordinator.data.get(f"{self.key_base}_energy_charges", {}) + self._attr_available = bool(self.seasons and self.charges) diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index 15965044771866..a82a712ec72a61 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -329,11 +329,11 @@ async def async_added_to_hass(self) -> None: ) ) - def _async_handle_inside_temp(self, data: float | None): + def _async_handle_inside_temp(self, data: float | None) -> None: self._attr_current_temperature = data self.async_write_ha_state() - def _async_handle_hvac_power(self, data: str | None): + def _async_handle_hvac_power(self, data: str | None) -> None: self._attr_hvac_mode = ( None if data is None @@ -343,15 +343,15 @@ def _async_handle_hvac_power(self, data: str | None): ) self.async_write_ha_state() - def _async_handle_climate_keeper_mode(self, data: str | None): + def _async_handle_climate_keeper_mode(self, data: str | None) -> None: self._attr_preset_mode = PRESET_MODES.get(data) if data else None self.async_write_ha_state() - def _async_handle_hvac_temperature_request(self, data: float | None): + def _async_handle_hvac_temperature_request(self, data: float | None) -> None: self._attr_target_temperature = data self.async_write_ha_state() - def _async_handle_rhd(self, data: bool | None): + def _async_handle_rhd(self, data: bool | None) -> None: if data is not None: self.rhd = data @@ -538,15 +538,15 @@ async def async_added_to_hass(self) -> None: ) ) - def _async_handle_inside_temp(self, value: float | None): + def _async_handle_inside_temp(self, value: float | None) -> None: self._attr_current_temperature = value self.async_write_ha_state() - def _async_handle_protection_mode(self, value: str | None): + def _async_handle_protection_mode(self, value: str | None) -> None: self._attr_hvac_mode = COP_MODES.get(value) if value is not None else None self.async_write_ha_state() - def _async_handle_temperature_limit(self, value: str | None): + def _async_handle_temperature_limit(self, value: str | None) -> None: self._attr_target_temperature = ( COP_LEVELS.get(value) if value is not None else None ) diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index 37e37d4478202b..11d6a95d796a92 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -15,7 +15,7 @@ SubscriptionRequired, TeslaFleetError, ) -from tesla_fleet_api.teslemetry import EnergySite, Vehicle +from tesla_fleet_api.teslemetry import EnergySite, Teslemetry, Vehicle from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -48,6 +48,7 @@ def _get_retry_after(e: TeslaFleetError) -> float: ENERGY_LIVE_INTERVAL = timedelta(seconds=30) ENERGY_INFO_INTERVAL = timedelta(seconds=30) ENERGY_HISTORY_INTERVAL = timedelta(seconds=60) +METADATA_INTERVAL = timedelta(hours=1) ENDPOINTS = [ VehicleDataEndpoint.CHARGE_STATE, @@ -59,6 +60,50 @@ def _get_retry_after(e: TeslaFleetError) -> float: ] +class TeslemetryMetadataCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Coordinator to poll for subscription changes via metadata.""" + + config_entry: TeslemetryConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: TeslemetryConfigEntry, + teslemetry: Teslemetry, + ) -> None: + """Initialize Teslemetry Metadata coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name="Teslemetry Metadata", + update_interval=METADATA_INTERVAL, + ) + self.teslemetry = teslemetry + + 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: + raise ConfigEntryAuthFailed from e + except RETRY_EXCEPTIONS as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"message": e.message}, + retry_after=_get_retry_after(e), + ) from e + except TeslaFleetError as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"message": e.message}, + ) from e + + return data + + class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching data from the Teslemetry API.""" @@ -70,7 +115,7 @@ def __init__( hass: HomeAssistant, config_entry: TeslemetryConfigEntry, api: Vehicle, - product: dict, + product: dict[str, Any], ) -> None: """Initialize Teslemetry Vehicle Update Coordinator.""" super().__init__( @@ -104,6 +149,7 @@ async def _async_update_data(self) -> dict[str, Any]: translation_domain=DOMAIN, translation_key="update_failed", ) from e + return flatten(data) @@ -118,7 +164,7 @@ def __init__( hass: HomeAssistant, config_entry: TeslemetryConfigEntry, api: EnergySite, - data: dict, + data: dict[str, Any], ) -> None: """Initialize Teslemetry Energy Site Live coordinator.""" super().__init__( @@ -139,7 +185,7 @@ def __init__( async def _async_update_data(self) -> dict[str, Any]: """Update energy site data using Teslemetry API.""" try: - data = (await self.api.live_status())["response"] + data: dict[str, Any] = (await self.api.live_status())["response"] except (InvalidToken, SubscriptionRequired) as e: raise ConfigEntryAuthFailed from e except RETRY_EXCEPTIONS as e: @@ -170,7 +216,7 @@ def __init__( hass: HomeAssistant, config_entry: TeslemetryConfigEntry, api: EnergySite, - product: dict, + product: dict[str, Any], ) -> None: """Initialize Teslemetry Energy Info coordinator.""" super().__init__( @@ -200,7 +246,11 @@ async def _async_update_data(self) -> dict[str, Any]: translation_domain=DOMAIN, translation_key="update_failed", ) from e - return flatten(data) + + return flatten( + data, + skip_keys=["daily_charges", "demand_charges", "energy_charges", "seasons"], + ) class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py index 5c86d6e19fe26b..ac683b7497d94f 100644 --- a/homeassistant/components/teslemetry/cover.py +++ b/homeassistant/components/teslemetry/cover.py @@ -199,7 +199,7 @@ async def async_added_to_hass(self) -> None: f"Adding field {signal} to {self.vehicle.vin}", ) - def _handle_stream_update(self, data) -> None: + def _handle_stream_update(self, data: dict[str, Any]) -> None: """Update the entity attributes.""" change = False diff --git a/homeassistant/components/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py index ce874565160b29..cf778e1f2680af 100644 --- a/homeassistant/components/teslemetry/entity.py +++ b/homeassistant/components/teslemetry/entity.py @@ -28,7 +28,7 @@ class TeslemetryRootEntity(Entity): _attr_has_entity_name = True scoped: bool - def raise_for_scope(self, scope: Scope): + def raise_for_scope(self, scope: Scope) -> None: """Raise an error if a scope is not available.""" if not self.scoped: raise ServiceValidationError( @@ -231,11 +231,12 @@ def __init__( @property def _value(self) -> StateType: """Return a specific wall connector value from coordinator data.""" - return ( + value: StateType = ( self.coordinator.data.get("wall_connectors", {}) .get(self.din, {}) .get(self.key) ) + return value @property def exists(self) -> bool: diff --git a/homeassistant/components/teslemetry/helpers.py b/homeassistant/components/teslemetry/helpers.py index e8afe8811ec045..834b8831d49f4b 100644 --- a/homeassistant/components/teslemetry/helpers.py +++ b/homeassistant/components/teslemetry/helpers.py @@ -1,5 +1,6 @@ """Teslemetry helper functions.""" +from collections.abc import Awaitable from typing import Any from tesla_fleet_api.exceptions import TeslaFleetError @@ -11,20 +12,26 @@ from .const import DOMAIN, LOGGER -def flatten(data: dict[str, Any], parent: str | None = None) -> dict[str, Any]: +def flatten( + data: dict[str, Any], + parent: str | None = None, + *, + skip_keys: list[str] | None = None, +) -> dict[str, Any]: """Flatten the data structure.""" result = {} for key, value in data.items(): + skip = skip_keys and key in skip_keys if parent: key = f"{parent}_{key}" - if isinstance(value, dict): - result.update(flatten(value, key)) + if isinstance(value, dict) and not skip: + result.update(flatten(value, key, skip_keys=skip_keys)) else: result[key] = value return result -async def handle_command(command) -> dict[str, Any]: +async def handle_command(command: Awaitable[dict[str, Any]]) -> dict[str, Any]: """Handle a command.""" try: result = await command @@ -38,7 +45,7 @@ async def handle_command(command) -> dict[str, Any]: return result -async def handle_vehicle_command(command) -> Any: +async def handle_vehicle_command(command: Awaitable[dict[str, Any]]) -> Any: """Handle a vehicle command.""" result = await handle_command(command) if (response := result.get("response")) is None: diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index d630060ea5d50f..44ade797d78b43 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -8,5 +8,6 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], + "quality_scale": "platinum", "requirements": ["tesla-fleet-api==1.4.3", "teslemetry-stream==0.9.0"] } diff --git a/homeassistant/components/teslemetry/models.py b/homeassistant/components/teslemetry/models.py index 3492e9da986af2..534e4a1bb67e29 100644 --- a/homeassistant/components/teslemetry/models.py +++ b/homeassistant/components/teslemetry/models.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass +from dataclasses import dataclass, field from tesla_fleet_api.const import Scope from tesla_fleet_api.teslemetry import EnergySite, Vehicle @@ -16,6 +16,7 @@ TeslemetryEnergyHistoryCoordinator, TeslemetryEnergySiteInfoCoordinator, TeslemetryEnergySiteLiveCoordinator, + TeslemetryMetadataCoordinator, TeslemetryVehicleDataCoordinator, ) @@ -28,6 +29,7 @@ class TeslemetryData: energysites: list[TeslemetryEnergyData] scopes: list[Scope] stream: TeslemetryStream | None + metadata_coordinator: TeslemetryMetadataCoordinator @dataclass @@ -43,7 +45,7 @@ class TeslemetryVehicleData: vin: str firmware: str device: DeviceInfo - wakelock = asyncio.Lock() + wakelock: asyncio.Lock = field(default_factory=asyncio.Lock) @dataclass diff --git a/homeassistant/components/teslemetry/quality_scale.yaml b/homeassistant/components/teslemetry/quality_scale.yaml index a3e26512cdb614..09110da055a60c 100644 --- a/homeassistant/components/teslemetry/quality_scale.yaml +++ b/homeassistant/components/teslemetry/quality_scale.yaml @@ -45,13 +45,7 @@ rules: docs-supported-functions: done docs-troubleshooting: done docs-use-cases: done - dynamic-devices: - status: todo - comment: | - New vehicles/energy sites added to user's Tesla account after initial setup - are not detected. Need to periodically poll teslemetry.products() and add - new TeslemetryVehicleData/TeslemetryEnergyData to runtime_data, then trigger - entity creation via coordinator listeners in each platform. + dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: done @@ -66,4 +60,4 @@ rules: # Platinum async-dependency: done inject-websession: done - strict-typing: todo + strict-typing: done diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index d70555eb288c73..54e463721cdab5 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -115,6 +115,13 @@ "Home": "home", } +CHARGE_CABLE_TYPES = { + "IEC": "iec", + "SAE": "sae", + "GB_AC": "gb_ac", + "GB_DC": "gb_dc", +} + FORWARD_COLLISION_SENSITIVITIES = { "Off": "off", "Late": "late", @@ -287,9 +294,14 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): TeslemetryVehicleSensorEntityDescription( key="charge_state_conn_charge_cable", polling=True, + polling_value_fn=lambda value: CHARGE_CABLE_TYPES.get(str(value)), streaming_listener=lambda vehicle, callback: vehicle.listen_ChargingCableType( - callback + lambda value: callback( + None if value is None else CHARGE_CABLE_TYPES.get(value) + ) ), + options=list(CHARGE_CABLE_TYPES.values()), + device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), diff --git a/homeassistant/components/teslemetry/services.py b/homeassistant/components/teslemetry/services.py index 8bbd002897bd96..53c7c52ac2bd7f 100644 --- a/homeassistant/components/teslemetry/services.py +++ b/homeassistant/components/teslemetry/services.py @@ -309,9 +309,12 @@ async def time_of_use(call: ServiceCall) -> None: config = async_get_config_for_device(hass, device) site = async_get_energy_site_for_entry(hass, device, config) - resp = await handle_command( - site.api.time_of_use_settings(call.data[ATTR_TOU_SETTINGS]) - ) + tou_settings = call.data[ATTR_TOU_SETTINGS] + # Unwrap tariff_content_v2 if user included it, since the SDK adds this wrapper + if "tariff_content_v2" in tou_settings: + tou_settings = tou_settings["tariff_content_v2"] + + resp = await handle_command(site.api.time_of_use_settings(tou_settings)) if "error" in resp: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 0c54a02e8d4754..6041f3d87c4702 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -272,6 +272,14 @@ "name": "Wake" } }, + "calendar": { + "tariff_content_v2": { + "name": "Buy tariff" + }, + "tariff_content_v2_sell_tariff": { + "name": "Sell tariff" + } + }, "climate": { "climate_state_cabin_overheat_protection": { "name": "Cabin overheat protection" @@ -519,7 +527,13 @@ } }, "charge_state_conn_charge_cable": { - "name": "Charge cable" + "name": "Charge cable", + "state": { + "gb_ac": "GB/AC", + "gb_dc": "GB/DC", + "iec": "IEC", + "sae": "SAE" + } }, "charge_state_est_battery_range": { "name": "Estimate battery range" diff --git a/homeassistant/components/teslemetry/update.py b/homeassistant/components/teslemetry/update.py index 253488d579dae3..d0e1d271636556 100644 --- a/homeassistant/components/teslemetry/update.py +++ b/homeassistant/components/teslemetry/update.py @@ -188,7 +188,7 @@ async def async_added_to_hass(self) -> None: def _async_handle_software_update_download_percent_complete( self, value: float | None - ): + ) -> None: """Handle software update download percent complete.""" self._download_percentage = round(value) if value is not None else 0 @@ -203,20 +203,22 @@ def _async_handle_software_update_download_percent_complete( def _async_handle_software_update_installation_percent_complete( self, value: float | None - ): + ) -> None: """Handle software update installation percent complete.""" self._install_percentage = round(value) if value is not None else 0 self._async_update_progress() self.async_write_ha_state() - def _async_handle_software_update_scheduled_start_time(self, value: str | None): + def _async_handle_software_update_scheduled_start_time( + self, value: str | None + ) -> None: """Handle software update scheduled start time.""" self._attr_in_progress = value is not None self.async_write_ha_state() - def _async_handle_software_update_version(self, value: str | None): + def _async_handle_software_update_version(self, value: str | None) -> None: """Handle software update version.""" self._attr_latest_version = ( @@ -224,7 +226,7 @@ def _async_handle_software_update_version(self, value: str | None): ) self.async_write_ha_state() - def _async_handle_version(self, value: str | None): + def _async_handle_version(self, value: str | None) -> None: """Handle version.""" if value is not None: diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index 41096ad167e4ff..2077b05cdc5460 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -18,7 +18,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo @@ -65,8 +69,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo except ClientResponseError as e: if e.status == HTTPStatus.UNAUTHORIZED: raise ConfigEntryAuthFailed from e - _LOGGER.error("Setup failed, unable to connect to Tessie: %s", e) - return False + raise ConfigEntryError("Setup failed, unable to connect to Tessie") from e except ClientError as e: raise ConfigEntryNotReady from e diff --git a/homeassistant/components/tessie/const.py b/homeassistant/components/tessie/const.py index b8846fa20735fb..5cd2e16913ca40 100644 --- a/homeassistant/components/tessie/const.py +++ b/homeassistant/components/tessie/const.py @@ -32,14 +32,6 @@ class TessieState(StrEnum): ONLINE = "online" -class TessieStatus(StrEnum): - """Tessie status.""" - - ASLEEP = "asleep" - AWAKE = "awake" - WAITING = "waiting_for_sleep" - - class TessieSeatHeaterOptions(StrEnum): """Tessie seat heater options.""" @@ -100,6 +92,12 @@ class TessieChargeCableLockStates(StrEnum): "NoPower": "no_power", } +TessieChargePortLatchStates = { + "Engaged": "engaged", + "Disengaged": "disengaged", + "Blocking": "blocking", +} + class TessieWallConnectorStates(IntEnum): """Tessie Wall Connector states.""" diff --git a/homeassistant/components/tessie/coordinator.py b/homeassistant/components/tessie/coordinator.py index ff2b7ff78d76cd..97e94c25c7c9c4 100644 --- a/homeassistant/components/tessie/coordinator.py +++ b/homeassistant/components/tessie/coordinator.py @@ -11,7 +11,7 @@ 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, get_status +from tessie_api import get_state from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -22,7 +22,7 @@ if TYPE_CHECKING: from . import TessieConfigEntry -from .const import DOMAIN, ENERGY_HISTORY_FIELDS, TessieStatus +from .const import DOMAIN, ENERGY_HISTORY_FIELDS # This matches the update interval Tessie performs server side TESSIE_SYNC_INTERVAL = 10 @@ -74,16 +74,6 @@ def __init__( async def _async_update_data(self) -> dict[str, Any]: """Update vehicle data using Tessie API.""" try: - status = await get_status( - session=self.session, - api_key=self.api_key, - vin=self.vin, - ) - if status["status"] == TessieStatus.ASLEEP: - # Vehicle is asleep, no need to poll for data - self.data["state"] = status["status"] - return self.data - vehicle = await get_state( session=self.session, api_key=self.api_key, @@ -92,10 +82,8 @@ async def _async_update_data(self) -> dict[str, Any]: ) except ClientResponseError as e: if e.status == HTTPStatus.UNAUTHORIZED: - # Auth Token is no longer valid raise ConfigEntryAuthFailed from e raise - return flatten(vehicle) diff --git a/homeassistant/components/tessie/diagnostics.py b/homeassistant/components/tessie/diagnostics.py index 21fc208612d779..8bf5d6399d1e59 100644 --- a/homeassistant/components/tessie/diagnostics.py +++ b/homeassistant/components/tessie/diagnostics.py @@ -35,7 +35,6 @@ async def async_get_config_entry_diagnostics( vehicles = [ { "data": async_redact_data(x.data_coordinator.data, VEHICLE_REDACT), - # Battery diag will go here when implemented } for x in entry.runtime_data.vehicles ] diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py index d4dec969f1cf4e..95ba212e222fec 100644 --- a/homeassistant/components/tessie/entity.py +++ b/homeassistant/components/tessie/entity.py @@ -39,10 +39,12 @@ def __init__( | TessieEnergySiteLiveCoordinator | TessieEnergyHistoryCoordinator, key: str, + data_key: str | None = None, ) -> None: """Initialize common aspects of a Tessie entity.""" self.key = key + self.data_key = data_key or key self._attr_translation_key = key super().__init__(coordinator) self._async_update_attrs() @@ -50,11 +52,11 @@ def __init__( @property def _value(self) -> Any: """Return value from coordinator data.""" - return self.coordinator.data.get(self.key) + return self.coordinator.data.get(self.data_key) def get(self, key: str | None = None, default: Any | None = None) -> Any: """Return a specific value from coordinator data.""" - return self.coordinator.data.get(key or self.key, default) + return self.coordinator.data.get(key or self.data_key, default) def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" @@ -73,6 +75,7 @@ def __init__( self, vehicle: TessieVehicleData, key: str, + data_key: str | None = None, ) -> None: """Initialize common aspects of a Tessie vehicle entity.""" self.vin = vehicle.vin @@ -81,12 +84,7 @@ def __init__( self._attr_unique_id = f"{vehicle.vin}-{key}" self._attr_device_info = vehicle.device - super().__init__(vehicle.data_coordinator, key) - - @property - def _value(self) -> Any: - """Return value from coordinator data.""" - return self.coordinator.data.get(self.key) + super().__init__(vehicle.data_coordinator, key, data_key) def set(self, *args: Any) -> None: """Set a value in coordinator data.""" @@ -130,13 +128,14 @@ def __init__( data: TessieEnergyData, coordinator: TessieEnergySiteInfoCoordinator | TessieEnergySiteLiveCoordinator, key: str, + data_key: str | None = None, ) -> None: """Initialize common aspects of a Tessie energy site entity.""" self.api = data.api self._attr_unique_id = f"{data.id}-{key}" self._attr_device_info = data.device - super().__init__(coordinator, key) + super().__init__(coordinator, key, data_key) class TessieEnergyHistoryEntity(TessieBaseEntity): @@ -146,13 +145,14 @@ def __init__( self, data: TessieEnergyData, key: str, + data_key: str | None = None, ) -> None: """Initialize common aspects of a Tessie energy history entity.""" self.api = data.api self._attr_unique_id = f"{data.id}-{key}" self._attr_device_info = data.device assert data.history_coordinator - super().__init__(data.history_coordinator, key) + super().__init__(data.history_coordinator, key, data_key) class TessieWallConnectorEntity(TessieBaseEntity): @@ -163,6 +163,7 @@ def __init__( data: TessieEnergyData, din: str, key: str, + data_key: str | None = None, ) -> None: """Initialize common aspects of a Teslemetry entity.""" self.din = din @@ -175,7 +176,7 @@ def __init__( serial_number=din.rsplit("-", maxsplit=1)[-1], ) assert data.live_coordinator - super().__init__(data.live_coordinator, key) + super().__init__(data.live_coordinator, key, data_key) @property def _value(self) -> int: diff --git a/homeassistant/components/tessie/icons.json b/homeassistant/components/tessie/icons.json index 5a67cdffb5fb2b..b90af3ddff038f 100644 --- a/homeassistant/components/tessie/icons.json +++ b/homeassistant/components/tessie/icons.json @@ -184,9 +184,15 @@ "battery_power": { "default": "mdi:home-battery" }, + "charge_state_charge_port_latch": { + "default": "mdi:ev-plug-tesla" + }, "charge_state_charging_state": { "default": "mdi:ev-station" }, + "charge_state_conn_charge_cable": { + "default": "mdi:ev-plug-ccs2" + }, "charge_state_energy_remaining": { "default": "mdi:battery-medium" }, @@ -220,9 +226,27 @@ "grid_services_power": { "default": "mdi:transmission-tower" }, + "lifetime_energy_used": { + "default": "mdi:battery-heart-variant" + }, "load_power": { "default": "mdi:power-plug" }, + "module_temp_max": { + "default": "mdi:thermometer-high" + }, + "module_temp_min": { + "default": "mdi:thermometer-low" + }, + "pack_current": { + "default": "mdi:current-dc" + }, + "pack_voltage": { + "default": "mdi:lightning-bolt" + }, + "phantom_drain_percent": { + "default": "mdi:battery-minus-outline" + }, "solar_power": { "default": "mdi:solar-power" }, diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index 309f7425a0f532..9d14cde7471f39 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -7,5 +7,6 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], + "quality_scale": "silver", "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.4.3"] } diff --git a/homeassistant/components/tessie/quality_scale.yaml b/homeassistant/components/tessie/quality_scale.yaml new file mode 100644 index 00000000000000..a814a4e0624131 --- /dev/null +++ b/homeassistant/components/tessie/quality_scale.yaml @@ -0,0 +1,90 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom actions are defined. Only entity-based actions exist. + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom actions are defined. Only entity-based actions exist. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Integration uses coordinators for data updates, no explicit 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: | + No custom actions are defined. Only entity-based actions exist. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + No options flow and no configurable options after initial setup. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: + status: done + comment: | + Handled by coordinators. + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery: + status: exempt + comment: | + Cloud-based service without local discovery capabilities. + discovery-update-info: + status: exempt + comment: | + Cloud-based service without local discovery capabilities. + docs-data-update: todo + 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: + status: todo + comment: | + Most user-facing exceptions have translations (HomeAssistantError and + ServiceValidationError use translation keys from strings.json). Remaining: + entity.py raises bare HomeAssistantError for ClientResponseError, and + coordinators raise UpdateFailed with untranslated messages. + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index 54b8031197df04..449cd0d7073be2 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -34,7 +34,12 @@ from homeassistant.util.variance import ignore_variance from . import TessieConfigEntry -from .const import ENERGY_HISTORY_FIELDS, TessieChargeStates, TessieWallConnectorStates +from .const import ( + ENERGY_HISTORY_FIELDS, + TessieChargePortLatchStates, + TessieChargeStates, + TessieWallConnectorStates, +) from .entity import ( TessieEnergyEntity, TessieEnergyHistoryEntity, @@ -56,6 +61,7 @@ def minutes_to_datetime(value: StateType) -> datetime | None: class TessieSensorEntityDescription(SensorEntityDescription): """Describes Tessie Sensor entity.""" + data_key: str | None = None value_fn: Callable[[StateType], StateType | datetime] = lambda x: x available_fn: Callable[[StateType], bool] = lambda _: True @@ -136,6 +142,14 @@ class TessieSensorEntityDescription(SensorEntityDescription): suggested_display_precision=1, entity_registry_enabled_default=False, ), + TessieSensorEntityDescription( + key="phantom_drain_percent", + data_key="charge_state_phantom_drain", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=2, + ), TessieSensorEntityDescription( key="charge_state_energy_remaining", state_class=SensorStateClass.MEASUREMENT, @@ -144,6 +158,64 @@ class TessieSensorEntityDescription(SensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, suggested_display_precision=2, ), + TessieSensorEntityDescription( + key="lifetime_energy_used", + data_key="charge_state_lifetime_energy_used", + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=1, + ), + TessieSensorEntityDescription( + key="pack_current", + data_key="charge_state_pack_current", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=1, + ), + TessieSensorEntityDescription( + key="pack_voltage", + data_key="charge_state_pack_voltage", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=1, + ), + TessieSensorEntityDescription( + key="module_temp_min", + data_key="charge_state_module_temp_min", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=1, + ), + TessieSensorEntityDescription( + key="module_temp_max", + data_key="charge_state_module_temp_max", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=1, + ), + TessieSensorEntityDescription( + key="charge_state_conn_charge_cable", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TessieSensorEntityDescription( + key="charge_state_charge_port_latch", + options=list(TessieChargePortLatchStates.values()), + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: TessieChargePortLatchStates[cast(str, value)], + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), TessieSensorEntityDescription( key="drive_state_speed", state_class=SensorStateClass.MEASUREMENT, @@ -271,7 +343,6 @@ class TessieSensorEntityDescription(SensorEntityDescription): ), ) - ENERGY_LIVE_DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( TessieSensorEntityDescription( key="solar_power", @@ -470,7 +541,7 @@ def __init__( ) -> None: """Initialize the sensor.""" self.entity_description = description - super().__init__(vehicle, description.key) + super().__init__(vehicle, description.key, description.data_key) @property def native_value(self) -> StateType | datetime: diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index c2f2a719397136..06516877db73f1 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -352,6 +352,14 @@ "charge_state_charge_energy_added": { "name": "Charge energy added" }, + "charge_state_charge_port_latch": { + "name": "Charge port latch", + "state": { + "blocking": "Blocking", + "disengaged": "Disengaged", + "engaged": "Engaged" + } + }, "charge_state_charge_rate": { "name": "Charge rate" }, @@ -375,6 +383,9 @@ "stopped": "[%key:common::state::stopped%]" } }, + "charge_state_conn_charge_cable": { + "name": "Charge cable" + }, "charge_state_energy_remaining": { "name": "Energy remaining" }, @@ -487,12 +498,30 @@ "on_grid": "On-grid" } }, + "lifetime_energy_used": { + "name": "Lifetime energy used" + }, "load_power": { "name": "Load power" }, + "module_temp_max": { + "name": "Battery module temperature max" + }, + "module_temp_min": { + "name": "Battery module temperature min" + }, + "pack_current": { + "name": "Battery pack current" + }, + "pack_voltage": { + "name": "Battery pack voltage" + }, "percentage_charged": { "name": "Percentage charged" }, + "phantom_drain_percent": { + "name": "Phantom drain" + }, "solar_energy_exported": { "name": "Solar exported" }, diff --git a/homeassistant/components/text/trigger.py b/homeassistant/components/text/trigger.py index d662a8c978c87c..7da70ab00b8310 100644 --- a/homeassistant/components/text/trigger.py +++ b/homeassistant/components/text/trigger.py @@ -2,6 +2,7 @@ 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, @@ -14,9 +15,15 @@ class TextChangedTrigger(EntityTriggerBase): """Trigger for text entity when its content changes.""" - _domain = DOMAIN + _domain_specs = {DOMAIN: DomainSpec()} _schema = ENTITY_STATE_TRIGGER_SCHEMA + def is_valid_transition(self, from_state: State, to_state: State) -> bool: + """Check if the origin state is valid and the state has changed.""" + if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + return False + return from_state.state != to_state.state + def is_valid_state(self, state: State) -> bool: """Check if the new state is not invalid.""" return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) diff --git a/homeassistant/components/tfiac/__init__.py b/homeassistant/components/tfiac/__init__.py deleted file mode 100644 index bb097a7edd0d6b..00000000000000 --- a/homeassistant/components/tfiac/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The tfiac component.""" diff --git a/homeassistant/components/tfiac/climate.py b/homeassistant/components/tfiac/climate.py deleted file mode 100644 index bab05bfc25eccd..00000000000000 --- a/homeassistant/components/tfiac/climate.py +++ /dev/null @@ -1,166 +0,0 @@ -"""Climate platform that offers a climate device for the TFIAC protocol.""" - -from __future__ import annotations - -from concurrent import futures -from datetime import timedelta -import logging -from typing import Any - -from pytfiac import Tfiac -import voluptuous as vol - -from homeassistant.components.climate import ( - FAN_AUTO, - FAN_HIGH, - FAN_LOW, - FAN_MEDIUM, - PLATFORM_SCHEMA as CLIMATE_PLATFORM_SCHEMA, - SWING_BOTH, - SWING_HORIZONTAL, - SWING_OFF, - SWING_VERTICAL, - ClimateEntity, - ClimateEntityFeature, - HVACMode, -) -from homeassistant.const import ATTR_TEMPERATURE, CONF_HOST, UnitOfTemperature -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -SCAN_INTERVAL = timedelta(seconds=60) - -PLATFORM_SCHEMA = CLIMATE_PLATFORM_SCHEMA.extend({vol.Required(CONF_HOST): cv.string}) - -_LOGGER = logging.getLogger(__name__) - -HVAC_MAP = { - HVACMode.HEAT: "heat", - HVACMode.AUTO: "selfFeel", - HVACMode.DRY: "dehumi", - HVACMode.FAN_ONLY: "fan", - HVACMode.COOL: "cool", - HVACMode.OFF: "off", -} - -HVAC_MAP_REV = {v: k for k, v in HVAC_MAP.items()} - -CURR_TEMP = "current_temp" -TARGET_TEMP = "target_temp" -OPERATION_MODE = "operation" -FAN_MODE = "fan_mode" -SWING_MODE = "swing_mode" -ON_MODE = "is_on" - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the TFIAC climate device.""" - tfiac_client = Tfiac(config[CONF_HOST]) - try: - await tfiac_client.update() - except futures.TimeoutError: - _LOGGER.error("Unable to connect to %s", config[CONF_HOST]) - return - async_add_entities([TfiacClimate(tfiac_client)]) - - -class TfiacClimate(ClimateEntity): - """TFIAC class.""" - - _attr_supported_features = ( - ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.SWING_MODE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT - _attr_min_temp = 61 - _attr_max_temp = 88 - _attr_fan_modes = [FAN_AUTO, FAN_HIGH, FAN_MEDIUM, FAN_LOW] - _attr_hvac_modes = list(HVAC_MAP) - _attr_swing_modes = [SWING_OFF, SWING_HORIZONTAL, SWING_VERTICAL, SWING_BOTH] - - def __init__(self, client: Tfiac) -> None: - """Init class.""" - self._client = client - - async def async_update(self) -> None: - """Update status via socket polling.""" - try: - await self._client.update() - self._attr_available = True - except futures.TimeoutError: - self._attr_available = False - - @property - def name(self): - """Return the name of the climate device.""" - return self._client.name - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._client.status["target_temp"] - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._client.status["current_temp"] - - @property - def hvac_mode(self) -> HVACMode | None: - """Return hvac operation ie. heat, cool mode. - - Need to be one of HVAC_MODE_*. - """ - if self._client.status[ON_MODE] != "on": - return HVACMode.OFF - - state = self._client.status["operation"] - return HVAC_MAP_REV.get(state) - - @property - def fan_mode(self) -> str: - """Return the fan setting.""" - return self._client.status["fan_mode"].lower() - - @property - def swing_mode(self) -> str: - """Return the swing setting.""" - return self._client.status["swing_mode"].lower() - - async def async_set_temperature(self, **kwargs: Any) -> None: - """Set new target temperature.""" - if (temp := kwargs.get(ATTR_TEMPERATURE)) is not None: - await self._client.set_state(TARGET_TEMP, temp) - - async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set new target hvac mode.""" - if hvac_mode == HVACMode.OFF: - await self._client.set_state(ON_MODE, "off") - else: - await self._client.set_state(OPERATION_MODE, HVAC_MAP[hvac_mode]) - - async def async_set_fan_mode(self, fan_mode: str) -> None: - """Set new fan mode.""" - await self._client.set_state(FAN_MODE, fan_mode.capitalize()) - - async def async_set_swing_mode(self, swing_mode: str) -> None: - """Set new swing mode.""" - await self._client.set_swing(swing_mode.capitalize()) - - async def async_turn_on(self) -> None: - """Turn device on.""" - await self._client.set_state(OPERATION_MODE) - - async def async_turn_off(self) -> None: - """Turn device off.""" - await self._client.set_state(ON_MODE, "off") diff --git a/homeassistant/components/tfiac/manifest.json b/homeassistant/components/tfiac/manifest.json deleted file mode 100644 index 94f82c99d21920..00000000000000 --- a/homeassistant/components/tfiac/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "domain": "tfiac", - "name": "Tfiac", - "codeowners": ["@fredrike", "@mellado"], - "disabled": "This integration is disabled because we cannot build a valid wheel.", - "documentation": "https://www.home-assistant.io/integrations/tfiac", - "iot_class": "local_polling", - "quality_scale": "legacy", - "requirements": ["pytfiac==0.4"] -} diff --git a/homeassistant/components/thermobeacon/manifest.json b/homeassistant/components/thermobeacon/manifest.json index 7223a34d683546..e1dbf9e44ebe44 100644 --- a/homeassistant/components/thermobeacon/manifest.json +++ b/homeassistant/components/thermobeacon/manifest.json @@ -53,6 +53,7 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermobeacon", + "integration_type": "device", "iot_class": "local_push", "requirements": ["thermobeacon-ble==0.10.0"] } diff --git a/homeassistant/components/thermopro/manifest.json b/homeassistant/components/thermopro/manifest.json index bee126b54e8afb..8608dfbc53838d 100644 --- a/homeassistant/components/thermopro/manifest.json +++ b/homeassistant/components/thermopro/manifest.json @@ -23,6 +23,7 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermopro", + "integration_type": "device", "iot_class": "local_push", "requirements": ["thermopro-ble==1.1.3"] } diff --git a/homeassistant/components/thinkingcleaner/switch.py b/homeassistant/components/thinkingcleaner/switch.py index 8397eeedc230cb..135045df3ffe2a 100644 --- a/homeassistant/components/thinkingcleaner/switch.py +++ b/homeassistant/components/thinkingcleaner/switch.py @@ -123,7 +123,7 @@ def is_update_locked(self): return True @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" if self.entity_description.key == "clean": return ( diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index e64a0a4afe7f1f..5afffd102f073f 100644 --- a/homeassistant/components/thread/dataset_store.py +++ b/homeassistant/components/thread/dataset_store.py @@ -6,6 +6,7 @@ import dataclasses from datetime import datetime import logging +from pprint import pformat from typing import Any, cast from propcache.api import cached_property @@ -14,6 +15,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.redact import REDACTED from homeassistant.helpers.singleton import singleton from homeassistant.helpers.storage import Store from homeassistant.util import dt as dt_util, ulid as ulid_util @@ -30,6 +32,24 @@ _LOGGER = logging.getLogger(__name__) +def _format_dataset( + dataset: dict[MeshcopTLVType | int, tlv_parser.MeshcopTLVItem], +) -> dict[str, str]: + """Format a parsed Thread dataset for logging. + + Returns a human-readable dict with enum field names as keys, redacting + NETWORKKEY and PSKC to avoid logging sensitive network credentials. + """ + result = {} + for key, value in dataset.items(): + name = key.name if isinstance(key, MeshcopTLVType) else str(key) + if key in (MeshcopTLVType.NETWORKKEY, MeshcopTLVType.PSKC): + result[name] = REDACTED + else: + result[name] = str(value) + return result + + class DatasetPreferredError(HomeAssistantError): """Raised when attempting to delete the preferred dataset.""" @@ -116,7 +136,8 @@ async def _async_migrate_func( or MeshcopTLVType.ACTIVETIMESTAMP not in entry.dataset ): _LOGGER.warning( - "Dropped invalid Thread dataset '%s'", entry.tlv + "Dropped invalid Thread dataset:\n%s", + pformat(_format_dataset(entry.dataset)), ) if entry.id == preferred_dataset: preferred_dataset = None @@ -125,12 +146,14 @@ async def _async_migrate_func( if entry.extended_pan_id in datasets: if datasets[entry.extended_pan_id].id == preferred_dataset: _LOGGER.warning( - ( - "Dropped duplicated Thread dataset '%s' " - "(duplicate of preferred dataset '%s')" + "Dropped duplicated Thread dataset" + " (duplicate of preferred dataset):\n%s\nkept:\n%s", + pformat(_format_dataset(entry.dataset)), + pformat( + _format_dataset( + datasets[entry.extended_pan_id].dataset + ) ), - entry.tlv, - datasets[entry.extended_pan_id].tlv, ) continue new_timestamp = cast( @@ -148,21 +171,21 @@ async def _async_migrate_func( new_timestamp.ticks, ): _LOGGER.warning( - ( - "Dropped duplicated Thread dataset '%s' " - "(duplicate of '%s')" + "Dropped duplicated Thread dataset:\n%s\nkept:\n%s", + pformat(_format_dataset(entry.dataset)), + pformat( + _format_dataset( + datasets[entry.extended_pan_id].dataset + ) ), - entry.tlv, - datasets[entry.extended_pan_id].tlv, ) continue _LOGGER.warning( - ( - "Dropped duplicated Thread dataset '%s' " - "(duplicate of '%s')" + "Dropped duplicated Thread dataset:\n%s\nkept:\n%s", + pformat( + _format_dataset(datasets[entry.extended_pan_id].dataset) ), - datasets[entry.extended_pan_id].tlv, - entry.tlv, + pformat(_format_dataset(entry.dataset)), ) datasets[entry.extended_pan_id] = entry data = { @@ -256,27 +279,32 @@ def async_add( tlv_parser.Timestamp, entry.dataset[MeshcopTLVType.ACTIVETIMESTAMP], ) - if (old_timestamp.seconds, old_timestamp.ticks) >= ( - new_timestamp.seconds, - new_timestamp.ticks, - ): - _LOGGER.warning( - ( - "Got dataset with same extended PAN ID and same or older active" - " timestamp, old dataset: '%s', new dataset: '%s'" - ), - entry.tlv, - tlv, + old_ts = (old_timestamp.seconds, old_timestamp.ticks) + new_ts = (new_timestamp.seconds, new_timestamp.ticks) + if old_ts >= new_ts: + # Silently accept if the only addition is WAKEUP_CHANNEL: + # it was added in OpenThread but the wake-up protocol isn't + # defined yet, so we treat it as if it were always present. + dataset_without_wakeup = { + k: v + for k, v in dataset.items() + if k != MeshcopTLVType.WAKEUP_CHANNEL + } + if old_ts > new_ts or dataset_without_wakeup != entry.dataset: + _LOGGER.warning( + "Got dataset with same extended PAN ID and same or older" + " active timestamp\nold:\n%s\nnew:\n%s", + pformat(_format_dataset(entry.dataset)), + pformat(_format_dataset(dataset)), + ) + return + elif _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Updating dataset with same extended PAN ID and newer" + " active timestamp\nold:\n%s\nnew:\n%s", + pformat(_format_dataset(entry.dataset)), + pformat(_format_dataset(dataset)), ) - return - _LOGGER.debug( - ( - "Updating dataset with same extended PAN ID and newer active " - "timestamp, old dataset: '%s', new dataset: '%s'" - ), - entry.tlv, - tlv, - ) self.datasets[entry.id] = dataclasses.replace( self.datasets[entry.id], tlv=tlv ) diff --git a/homeassistant/components/thread/discovery.py b/homeassistant/components/thread/discovery.py index 34e909d7096b5b..4709162ee4bd4c 100644 --- a/homeassistant/components/thread/discovery.py +++ b/homeassistant/components/thread/discovery.py @@ -36,6 +36,7 @@ "Nanoleaf": "nanoleaf", "OpenThread": "openthread", "Samsung": "samsung", + "SmartThings": "smartthings", } THREAD_TYPE = "_meshcop._udp.local." CLASS_IN = 1 diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index 6424a174402e55..a00f7480ede3be 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.8.0", "pyroute2==0.7.5"], + "requirements": ["python-otbr-api==2.9.0", "pyroute2==0.7.5"], "single_config_entry": true, "zeroconf": ["_meshcop._udp.local."] } diff --git a/homeassistant/components/tibber/coordinator.py b/homeassistant/components/tibber/coordinator.py index 43e51bc8c45979..75a76326146149 100644 --- a/homeassistant/components/tibber/coordinator.py +++ b/homeassistant/components/tibber/coordinator.py @@ -2,9 +2,10 @@ from __future__ import annotations -from datetime import timedelta +import asyncio +from datetime import datetime, timedelta import logging -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, TypedDict, cast from aiohttp.client_exceptions import ClientError import tibber @@ -38,6 +39,58 @@ _LOGGER = logging.getLogger(__name__) +class TibberHomeData(TypedDict): + """Data for a Tibber home used by the price sensor.""" + + currency: str + price_unit: str + current_price: float | None + current_price_time: datetime | None + intraday_price_ranking: float | None + max_price: float + avg_price: float + min_price: float + off_peak_1: float + peak: float + off_peak_2: float + month_cost: float | None + peak_hour: float | None + peak_hour_time: datetime | None + month_cons: float | None + app_nickname: str | None + grid_company: str | None + estimated_annual_consumption: int | None + + +def _build_home_data(home: tibber.TibberHome) -> TibberHomeData: + """Build TibberHomeData from a TibberHome for the price sensor.""" + current_price, last_updated, price_rank = home.current_price_data() + attributes = home.current_attributes() + result: TibberHomeData = { + "currency": home.currency, + "price_unit": home.price_unit, + "current_price": current_price, + "current_price_time": last_updated, + "intraday_price_ranking": price_rank, + "max_price": attributes["max_price"], + "avg_price": attributes["avg_price"], + "min_price": attributes["min_price"], + "off_peak_1": attributes["off_peak_1"], + "peak": attributes["peak"], + "off_peak_2": attributes["off_peak_2"], + "month_cost": home.month_cost, + "peak_hour": home.peak_hour, + "peak_hour_time": home.peak_hour_time, + "month_cons": home.month_cons, + "app_nickname": home.info["viewer"]["home"].get("appNickname"), + "grid_company": home.info["viewer"]["home"]["meteringPointData"]["gridCompany"], + "estimated_annual_consumption": home.info["viewer"]["home"][ + "meteringPointData" + ]["estimatedAnnualConsumption"], + } + return result + + class TibberDataCoordinator(DataUpdateCoordinator[None]): """Handle Tibber data and insert statistics.""" @@ -57,13 +110,16 @@ def __init__( name=f"Tibber {tibber_connection.name}", update_interval=timedelta(minutes=20), ) - self._tibber_connection = tibber_connection async def _async_update_data(self) -> None: """Update data via API.""" + tibber_connection = await self.config_entry.runtime_data.async_get_client( + self.hass + ) + try: - await self._tibber_connection.fetch_consumption_data_active_homes() - await self._tibber_connection.fetch_production_data_active_homes() + await tibber_connection.fetch_consumption_data_active_homes() + await tibber_connection.fetch_production_data_active_homes() await self._insert_statistics() except tibber.RetryableHttpExceptionError as err: raise UpdateFailed(f"Error communicating with API ({err.status})") from err @@ -75,7 +131,10 @@ async def _async_update_data(self) -> None: async def _insert_statistics(self) -> None: """Insert Tibber statistics.""" - for home in self._tibber_connection.get_homes(): + tibber_connection = await self.config_entry.runtime_data.async_get_client( + self.hass + ) + for home in tibber_connection.get_homes(): sensors: list[tuple[str, bool, str | None, str]] = [] if home.hourly_consumption_data: sensors.append( @@ -194,6 +253,76 @@ async def _insert_statistics(self) -> None: async_add_external_statistics(self.hass, metadata, statistics) +class TibberPriceCoordinator(DataUpdateCoordinator[dict[str, TibberHomeData]]): + """Handle Tibber price data and insert statistics.""" + + config_entry: TibberConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: TibberConfigEntry, + ) -> None: + """Initialize the price coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=f"{DOMAIN} price", + update_interval=timedelta(minutes=1), + ) + + def _seconds_until_next_15_minute(self) -> float: + """Return seconds until the next 15-minute boundary (0, 15, 30, 45) in UTC.""" + now = dt_util.utcnow() + next_minute = ((now.minute // 15) + 1) * 15 + if next_minute >= 60: + next_run = now.replace(minute=0, second=0, microsecond=0) + timedelta( + hours=1 + ) + else: + next_run = now.replace( + minute=next_minute, second=0, microsecond=0, tzinfo=dt_util.UTC + ) + return (next_run - now).total_seconds() + + async def _async_update_data(self) -> dict[str, TibberHomeData]: + """Update data via API and return per-home data for sensors.""" + tibber_connection = await self.config_entry.runtime_data.async_get_client( + self.hass + ) + active_homes = tibber_connection.get_homes(only_active=True) + try: + await asyncio.gather( + tibber_connection.fetch_consumption_data_active_homes(), + tibber_connection.fetch_production_data_active_homes(), + ) + + now = dt_util.now() + homes_to_update = [ + home + for home in active_homes + if ( + (last_data_timestamp := home.last_data_timestamp) is None + or (last_data_timestamp - now).total_seconds() < 11 * 3600 + ) + ] + + if homes_to_update: + await asyncio.gather( + *(home.update_info_and_price_info() for home in homes_to_update) + ) + except tibber.RetryableHttpExceptionError as err: + raise UpdateFailed(f"Error communicating with API ({err.status})") from err + except tibber.FatalHttpExceptionError as err: + raise UpdateFailed(f"Error communicating with API ({err.status})") from err + + result = {home.home_id: _build_home_data(home) for home in active_homes} + + self.update_interval = timedelta(seconds=self._seconds_until_next_15_minute()) + return result + + class TibberDataAPICoordinator(DataUpdateCoordinator[dict[str, TibberDevice]]): """Fetch and cache Tibber Data API device capabilities.""" diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index d44a6b64008b13..14f4f26a81bc14 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -5,7 +5,8 @@ "config_flow": true, "dependencies": ["application_credentials", "recorder"], "documentation": "https://www.home-assistant.io/integrations/tibber", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["tibber"], - "requirements": ["pyTibber==0.35.0"] + "requirements": ["pyTibber==0.36.0"] } diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 9dc5620327cadf..008e3abef28c11 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -3,10 +3,8 @@ from __future__ import annotations from collections.abc import Callable -import datetime from datetime import timedelta import logging -from random import randrange from typing import Any import aiohttp @@ -42,18 +40,20 @@ CoordinatorEntity, DataUpdateCoordinator, ) -from homeassistant.util import Throttle, dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN, MANUFACTURER, TibberConfigEntry -from .coordinator import TibberDataAPICoordinator, TibberDataCoordinator +from .coordinator import ( + TibberDataAPICoordinator, + TibberDataCoordinator, + TibberPriceCoordinator, +) _LOGGER = logging.getLogger(__name__) ICON = "mdi:currency-usd" SCAN_INTERVAL = timedelta(minutes=1) -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) PARALLEL_UPDATES = 0 -TWENTY_MINUTES = 20 * 60 RT_SENSORS_UNIQUE_ID_MIGRATION = { "accumulated_consumption_last_hour": "accumulated consumption current hour", @@ -610,6 +610,7 @@ async def _async_setup_graphql_sensors( entity_registry = er.async_get(hass) coordinator: TibberDataCoordinator | None = None + price_coordinator: TibberPriceCoordinator | None = None entities: list[TibberSensor] = [] for home in tibber_connection.get_homes(only_active=False): try: @@ -626,7 +627,9 @@ async def _async_setup_graphql_sensors( raise PlatformNotReady from err if home.has_active_subscription: - entities.append(TibberSensorElPrice(home)) + if price_coordinator is None: + price_coordinator = TibberPriceCoordinator(hass, entry) + entities.append(TibberSensorElPrice(price_coordinator, home)) if coordinator is None: coordinator = TibberDataCoordinator(hass, entry, tibber_connection) entities.extend( @@ -737,19 +740,21 @@ def device_info(self) -> DeviceInfo: return device_info -class TibberSensorElPrice(TibberSensor): +class TibberSensorElPrice(TibberSensor, CoordinatorEntity[TibberPriceCoordinator]): """Representation of a Tibber sensor for el price.""" _attr_state_class = SensorStateClass.MEASUREMENT _attr_translation_key = "electricity_price" - def __init__(self, tibber_home: TibberHome) -> None: + def __init__( + self, + coordinator: TibberPriceCoordinator, + tibber_home: TibberHome, + ) -> None: """Initialize the sensor.""" - super().__init__(tibber_home=tibber_home) - self._last_updated: datetime.datetime | None = None - self._spread_load_constant = randrange(TWENTY_MINUTES) - + super().__init__(coordinator=coordinator, tibber_home=tibber_home) self._attr_available = False + self._attr_native_unit_of_measurement = tibber_home.price_unit self._attr_extra_state_attributes = { "app_nickname": None, "grid_company": None, @@ -768,51 +773,38 @@ def __init__(self, tibber_home: TibberHome) -> None: self._device_name = self._home_name - async def async_update(self) -> None: - """Get the latest data and updates the states.""" - now = dt_util.now() - if ( - not self._tibber_home.last_data_timestamp - or (self._tibber_home.last_data_timestamp - now).total_seconds() - < 10 * 3600 - self._spread_load_constant - or not self.available - ): - _LOGGER.debug("Asking for new data") - await self._fetch_data() - - elif ( - self._tibber_home.price_total - and self._last_updated - and self._last_updated.hour == now.hour - and now - self._last_updated < timedelta(minutes=15) - and self._tibber_home.last_data_timestamp + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + data = self.coordinator.data + if not data or ( + (home_data := data.get(self._tibber_home.home_id)) is None + or (current_price := home_data.get("current_price")) is None ): + self._attr_available = False + self.async_write_ha_state() return - res = self._tibber_home.current_price_data() - self._attr_native_value, self._last_updated, price_rank = res - self._attr_extra_state_attributes["intraday_price_ranking"] = price_rank - - attrs = self._tibber_home.current_attributes() - self._attr_extra_state_attributes.update(attrs) - self._attr_available = self._attr_native_value is not None - self._attr_native_unit_of_measurement = self._tibber_home.price_unit - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def _fetch_data(self) -> None: - _LOGGER.debug("Fetching data") - try: - await self._tibber_home.update_info_and_price_info() - except TimeoutError, aiohttp.ClientError: - return - data = self._tibber_home.info["viewer"]["home"] - self._attr_extra_state_attributes["app_nickname"] = data["appNickname"] - self._attr_extra_state_attributes["grid_company"] = data["meteringPointData"][ - "gridCompany" + self._attr_native_unit_of_measurement = home_data.get( + "price_unit", self._tibber_home.price_unit + ) + self._attr_native_value = current_price + self._attr_extra_state_attributes["intraday_price_ranking"] = home_data.get( + "intraday_price_ranking" + ) + self._attr_extra_state_attributes["max_price"] = home_data["max_price"] + self._attr_extra_state_attributes["avg_price"] = home_data["avg_price"] + self._attr_extra_state_attributes["min_price"] = home_data["min_price"] + self._attr_extra_state_attributes["off_peak_1"] = home_data["off_peak_1"] + self._attr_extra_state_attributes["peak"] = home_data["peak"] + self._attr_extra_state_attributes["off_peak_2"] = home_data["off_peak_2"] + self._attr_extra_state_attributes["app_nickname"] = home_data["app_nickname"] + self._attr_extra_state_attributes["grid_company"] = home_data["grid_company"] + self._attr_extra_state_attributes["estimated_annual_consumption"] = home_data[ + "estimated_annual_consumption" ] - self._attr_extra_state_attributes["estimated_annual_consumption"] = data[ - "meteringPointData" - ]["estimatedAnnualConsumption"] + self._attr_available = True + self.async_write_ha_state() class TibberDataSensor(TibberSensor, CoordinatorEntity[TibberDataCoordinator]): diff --git a/homeassistant/components/tilt_ble/manifest.json b/homeassistant/components/tilt_ble/manifest.json index f43e480a8f8b37..1036ccda773b7d 100644 --- a/homeassistant/components/tilt_ble/manifest.json +++ b/homeassistant/components/tilt_ble/manifest.json @@ -11,6 +11,7 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/tilt_ble", + "integration_type": "device", "iot_class": "local_push", "requirements": ["tilt-ble==1.0.1"] } diff --git a/homeassistant/components/tilt_pi/manifest.json b/homeassistant/components/tilt_pi/manifest.json index 94c6b7ade8660d..00c837e7b32239 100644 --- a/homeassistant/components/tilt_pi/manifest.json +++ b/homeassistant/components/tilt_pi/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@michaelheyman"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tilt_pi", + "integration_type": "hub", "iot_class": "local_polling", "quality_scale": "bronze", "requirements": ["tilt-pi==0.2.1"] diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 3cf8307e9b3af5..85745aea8e427b 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -140,8 +140,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def reload_service_handler(service_call: ServiceCall) -> None: """Reload yaml entities.""" conf = await component.async_prepare_reload(skip_reset=True) - if conf is None: - conf = {DOMAIN: {}} await yaml_collection.async_load( [{CONF_ID: id_, **cfg} for id_, cfg in conf.get(DOMAIN, {}).items()] ) diff --git a/homeassistant/components/tmb/sensor.py b/homeassistant/components/tmb/sensor.py index cbf3b073578df4..0d9f6ff8fb2532 100644 --- a/homeassistant/components/tmb/sensor.py +++ b/homeassistant/components/tmb/sensor.py @@ -4,6 +4,7 @@ from datetime import timedelta import logging +from typing import Any from requests import HTTPError from tmb import IBus @@ -108,7 +109,7 @@ def native_value(self): return self._state @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the last update.""" return { ATTR_BUS_STOP: self._stop, diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index 86004f931cccba..c7fd6236abc7f2 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Iterable +import copy import dataclasses import datetime import logging @@ -28,7 +29,6 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util -from homeassistant.util.json import JsonValueType from .const import ( ATTR_DESCRIPTION, @@ -240,7 +240,7 @@ class TodoListEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """An entity that represents a To-do list.""" _attr_todo_items: list[TodoItem] | None = None - _update_listeners: list[Callable[[list[JsonValueType] | None], None]] | None = None + _update_listeners: list[Callable[[list[TodoItem]], None]] | None = None @property def state(self) -> int | None: @@ -281,13 +281,9 @@ async def async_move_todo_item( @final @callback def async_subscribe_updates( - self, - listener: Callable[[list[JsonValueType] | None], None], + self, listener: Callable[[list[TodoItem]], None] ) -> CALLBACK_TYPE: - """Subscribe to To-do list item updates. - - Called by websocket API. - """ + """Subscribe to To-do list item updates.""" if self._update_listeners is None: self._update_listeners = [] self._update_listeners.append(listener) @@ -306,9 +302,7 @@ def async_update_listeners(self) -> None: if not self._update_listeners: return - todo_items: list[JsonValueType] = [ - dataclasses.asdict(item) for item in self.todo_items or () - ] + todo_items = [copy.copy(item) for item in self.todo_items or []] for listener in self._update_listeners: listener(todo_items) @@ -341,13 +335,13 @@ async def websocket_handle_subscribe_todo_items( return @callback - def todo_item_listener(todo_items: list[JsonValueType] | None) -> None: + def todo_item_listener(todo_items: list[TodoItem]) -> None: """Push updated To-do list items to websocket.""" connection.send_message( websocket_api.event_message( msg["id"], { - "items": todo_items, + "items": [dataclasses.asdict(item) for item in todo_items], }, ) ) @@ -357,7 +351,7 @@ def todo_item_listener(todo_items: list[JsonValueType] | None) -> None: ) connection.send_result(msg["id"]) - # Push an initial forecast update + # Push an initial list update entity.async_update_listeners() diff --git a/homeassistant/components/todoist/manifest.json b/homeassistant/components/todoist/manifest.json index 67526a85b65b16..2c67ea079e7fbc 100644 --- a/homeassistant/components/todoist/manifest.json +++ b/homeassistant/components/todoist/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@boralyl"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/todoist", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["todoist"], "requirements": ["todoist-api-python==3.1.0"] diff --git a/homeassistant/components/togrill/__init__.py b/homeassistant/components/togrill/__init__.py index f7e6568575e753..280a23ba538304 100644 --- a/homeassistant/components/togrill/__init__.py +++ b/homeassistant/components/togrill/__init__.py @@ -10,9 +10,9 @@ _PLATFORMS: list[Platform] = [ Platform.EVENT, + Platform.NUMBER, Platform.SELECT, Platform.SENSOR, - Platform.NUMBER, ] diff --git a/homeassistant/components/togrill/coordinator.py b/homeassistant/components/togrill/coordinator.py index 6d01e279b051e0..6f2419ef821074 100644 --- a/homeassistant/components/togrill/coordinator.py +++ b/homeassistant/components/togrill/coordinator.py @@ -32,7 +32,7 @@ from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_PROBE_COUNT, DOMAIN +from .const import CONF_HAS_AMBIENT, CONF_PROBE_COUNT, DOMAIN type ToGrillConfigEntry = ConfigEntry[ToGrillCoordinator] @@ -213,6 +213,8 @@ async def _async_update_data(self) -> dict[tuple[int, int | None], Packet]: await client.request(PacketA1Notify) for probe in range(1, self.config_entry.data[CONF_PROBE_COUNT] + 1): await client.write(PacketA8Write(probe=probe)) + if self.config_entry.data.get(CONF_HAS_AMBIENT): + await client.write(PacketA8Write(probe=0)) except BleakError as exc: raise DeviceFailed(f"Device failed {exc}") from exc return self.data diff --git a/homeassistant/components/togrill/manifest.json b/homeassistant/components/togrill/manifest.json index 429ffeab9ce040..9897c9921d39dc 100644 --- a/homeassistant/components/togrill/manifest.json +++ b/homeassistant/components/togrill/manifest.json @@ -12,6 +12,7 @@ "config_flow": true, "dependencies": ["bluetooth"], "documentation": "https://www.home-assistant.io/integrations/togrill", + "integration_type": "device", "iot_class": "local_push", "loggers": ["togrill_bluetooth"], "quality_scale": "bronze", diff --git a/homeassistant/components/togrill/number.py b/homeassistant/components/togrill/number.py index 9499bb49e01fe0..fa6f0b69ae8fb2 100644 --- a/homeassistant/components/togrill/number.py +++ b/homeassistant/components/togrill/number.py @@ -27,7 +27,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ToGrillConfigEntry -from .const import CONF_PROBE_COUNT, MAX_PROBE_COUNT +from .const import CONF_HAS_AMBIENT, CONF_PROBE_COUNT, MAX_PROBE_COUNT from .coordinator import ToGrillCoordinator from .entity import ToGrillEntity @@ -123,12 +123,64 @@ def _set_maximum( ) +def _get_ambient_temperatures( + coordinator: ToGrillCoordinator, alarm_type: AlarmType +) -> tuple[float | None, float | None]: + if not (packet := coordinator.get_packet(PacketA8Notify, 0)): + return None, None + if packet.alarm_type != alarm_type: + return None, None + return packet.temperature_1, packet.temperature_2 + + ENTITY_DESCRIPTIONS = ( *[ description for probe_number in range(1, MAX_PROBE_COUNT + 1) for description in _get_temperature_descriptions(probe_number) ], + ToGrillNumberEntityDescription( + key="ambient_temperature_minimum", + translation_key="ambient_temperature_minimum", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + native_min_value=0, + native_max_value=400, + mode=NumberMode.BOX, + icon="mdi:thermometer-chevron-down", + set_packet=lambda coordinator, value: PacketA300Write( + probe=0, + minimum=None if value == 0.0 else value, + maximum=_get_ambient_temperatures(coordinator, AlarmType.TEMPERATURE_RANGE)[ + 1 + ], + ), + get_value=lambda x: _get_ambient_temperatures(x, AlarmType.TEMPERATURE_RANGE)[ + 0 + ], + entity_supported=lambda x: x.get(CONF_HAS_AMBIENT, False), + ), + ToGrillNumberEntityDescription( + key="ambient_temperature_maximum", + translation_key="ambient_temperature_maximum", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + native_min_value=0, + native_max_value=400, + mode=NumberMode.BOX, + icon="mdi:thermometer-chevron-up", + set_packet=lambda coordinator, value: PacketA300Write( + probe=0, + minimum=_get_ambient_temperatures(coordinator, AlarmType.TEMPERATURE_RANGE)[ + 0 + ], + maximum=None if value == 0.0 else value, + ), + get_value=lambda x: _get_ambient_temperatures(x, AlarmType.TEMPERATURE_RANGE)[ + 1 + ], + entity_supported=lambda x: x.get(CONF_HAS_AMBIENT, False), + ), ToGrillNumberEntityDescription( key="alarm_interval", translation_key="alarm_interval", diff --git a/homeassistant/components/togrill/strings.json b/homeassistant/components/togrill/strings.json index 2db82fe6f46929..41b5c036b0ec01 100644 --- a/homeassistant/components/togrill/strings.json +++ b/homeassistant/components/togrill/strings.json @@ -55,6 +55,12 @@ "alarm_interval": { "name": "Alarm interval" }, + "ambient_temperature_maximum": { + "name": "Ambient maximum temperature" + }, + "ambient_temperature_minimum": { + "name": "Ambient minimum temperature" + }, "temperature_maximum": { "name": "Maximum temperature" }, diff --git a/homeassistant/components/tolo/manifest.json b/homeassistant/components/tolo/manifest.json index 613fc810683cf5..85ca3666156158 100644 --- a/homeassistant/components/tolo/manifest.json +++ b/homeassistant/components/tolo/manifest.json @@ -9,6 +9,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/tolo", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["tololib"], "requirements": ["tololib==1.2.2"] diff --git a/homeassistant/components/tomorrowio/entity.py b/homeassistant/components/tomorrowio/entity.py index 6560ac58724dc3..f00677b1561bce 100644 --- a/homeassistant/components/tomorrowio/entity.py +++ b/homeassistant/components/tomorrowio/entity.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + from pytomorrowio.const import CURRENT from homeassistant.config_entries import ConfigEntry @@ -36,7 +38,7 @@ def __init__( entry_type=DeviceEntryType.SERVICE, ) - def _get_current_property(self, property_name: str) -> int | str | float | None: + def _get_current_property(self, property_name: str) -> Any | None: """Get property from current conditions. Used for V4 API. diff --git a/homeassistant/components/tomorrowio/weather.py b/homeassistant/components/tomorrowio/weather.py index 0a070a1b33b679..36b85515c3c215 100644 --- a/homeassistant/components/tomorrowio/weather.py +++ b/homeassistant/components/tomorrowio/weather.py @@ -175,37 +175,37 @@ def _translate_condition( return CONDITIONS[condition] @property - def native_temperature(self): + def native_temperature(self) -> float | None: """Return the platform temperature.""" return self._get_current_property(TMRW_ATTR_TEMPERATURE) @property - def native_pressure(self): + def native_pressure(self) -> float | None: """Return the raw pressure.""" return self._get_current_property(TMRW_ATTR_PRESSURE) @property - def humidity(self): + def humidity(self) -> float | None: """Return the humidity.""" return self._get_current_property(TMRW_ATTR_HUMIDITY) @property - def native_wind_speed(self): + def native_wind_speed(self) -> float | None: """Return the raw wind speed.""" return self._get_current_property(TMRW_ATTR_WIND_SPEED) @property - def wind_bearing(self): + def wind_bearing(self) -> float | None: """Return the wind bearing.""" return self._get_current_property(TMRW_ATTR_WIND_DIRECTION) @property - def ozone(self): + def ozone(self) -> float | None: """Return the O3 (ozone) level.""" return self._get_current_property(TMRW_ATTR_OZONE) @property - def condition(self): + def condition(self) -> str | None: """Return the condition.""" return self._translate_condition( self._get_current_property(TMRW_ATTR_CONDITION), @@ -213,7 +213,7 @@ def condition(self): ) @property - def native_visibility(self): + def native_visibility(self) -> float | None: """Return the raw visibility.""" return self._get_current_property(TMRW_ATTR_VISIBILITY) diff --git a/homeassistant/components/toon/manifest.json b/homeassistant/components/toon/manifest.json index 5e5af3940749d1..17755a6e0b62c1 100644 --- a/homeassistant/components/toon/manifest.json +++ b/homeassistant/components/toon/manifest.json @@ -12,6 +12,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/toon", + "integration_type": "device", "iot_class": "cloud_push", "loggers": ["toonapi"], "requirements": ["toonapi==0.3.0"] diff --git a/homeassistant/components/torque/sensor.py b/homeassistant/components/torque/sensor.py index 8d4183e2961709..01dbf0237abf36 100644 --- a/homeassistant/components/torque/sensor.py +++ b/homeassistant/components/torque/sensor.py @@ -131,34 +131,15 @@ def get(self, request: web.Request) -> str | None: class TorqueSensor(SensorEntity): """Representation of a Torque sensor.""" + _attr_icon = "mdi:car" + def __init__(self, name, unit): """Initialize the sensor.""" - self._name = name - self._unit = unit - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def icon(self): - """Return the default icon of the sensor.""" - return "mdi:car" + self._attr_name = name + self._attr_native_unit_of_measurement = unit @callback def async_on_update(self, value): """Receive an update.""" - self._state = value + self._attr_native_value = value self.async_write_ha_state() diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json index db9a53ac154909..699bb8a7d762e4 100644 --- a/homeassistant/components/totalconnect/manifest.json +++ b/homeassistant/components/totalconnect/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@austinmroczek"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/totalconnect", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["total_connect_client"], "requirements": ["total-connect-client==2025.12.2"] diff --git a/homeassistant/components/touchline_sl/entity.py b/homeassistant/components/touchline_sl/entity.py index 637ad8955eb833..773ba6dfef75fd 100644 --- a/homeassistant/components/touchline_sl/entity.py +++ b/homeassistant/components/touchline_sl/entity.py @@ -35,4 +35,8 @@ def zone(self) -> Zone: @property def available(self) -> bool: """Return if the device is available.""" - return super().available and self.zone_id in self.coordinator.data.zones + return ( + super().available + and self.zone_id in self.coordinator.data.zones + and self.zone.alarm is None + ) diff --git a/homeassistant/components/tplink_omada/coordinator.py b/homeassistant/components/tplink_omada/coordinator.py index 956d53558096fc..8191a47c6c775e 100644 --- a/homeassistant/components/tplink_omada/coordinator.py +++ b/homeassistant/components/tplink_omada/coordinator.py @@ -159,7 +159,7 @@ class FirmwareUpdateStatus(NamedTuple): firmware: OmadaFirmwareUpdate | None -class OmadaFirmwareUpdateCoordinator(OmadaCoordinator[FirmwareUpdateStatus]): # pylint: disable=hass-enforce-class-module +class OmadaFirmwareUpdateCoordinator(OmadaCoordinator[FirmwareUpdateStatus]): """Coordinator for getting details about available firmware updates for Omada devices.""" def __init__( diff --git a/homeassistant/components/tplink_omada/quality_scale.yaml b/homeassistant/components/tplink_omada/quality_scale.yaml index 0feda35f46e332..ace158c44ea87b 100644 --- a/homeassistant/components/tplink_omada/quality_scale.yaml +++ b/homeassistant/components/tplink_omada/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 diff --git a/homeassistant/components/traccar/strings.json b/homeassistant/components/traccar/strings.json index 7bf76eff33a61c..35c2d583c2fe02 100644 --- a/homeassistant/components/traccar/strings.json +++ b/homeassistant/components/traccar/strings.json @@ -2,6 +2,7 @@ "config": { "abort": { "cloud_not_connected": "[%key:common::config_flow::abort::cloud_not_connected%]", + "reconfigure_successful": "**Reconfiguration was successful**\n\nGo to webhooks in the Traccar Client and update the webhook with the following URL: `{webhook_url}`\n\nSee [the documentation]({docs_url}) for further details.", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]" }, @@ -9,6 +10,10 @@ "default": "To send events to Home Assistant, you will need to set up the webhook feature in Traccar Client.\n\nUse the following URL: `{webhook_url}`\n\nSee [the documentation]({docs_url}) for further details." }, "step": { + "reconfigure": { + "description": "Are you sure you want to reconfigure the Traccar Client?", + "title": "Reconfigure Traccar Client" + }, "user": { "description": "Are you sure you want to set up Traccar Client?", "title": "Set up Traccar Client" diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index a8e0f451d09c0f..e5c20e757eaef5 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -9,7 +9,7 @@ import aiotractive -from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_CHARGING, @@ -136,7 +136,7 @@ async def cancel_listen_task(_: Event) -> None: for item in filtered_trackables: for key in ("activity_label", "calories", "sleep_label"): if entity_id := entity_reg.async_get_entity_id( - SENSOR_PLATFORM, DOMAIN, f"{item.trackable['_id']}_{key}" + SENSOR_DOMAIN, DOMAIN, f"{item.trackable['_id']}_{key}" ): entity_reg.async_remove(entity_id) diff --git a/homeassistant/components/tradfri/manifest.json b/homeassistant/components/tradfri/manifest.json index c411c52146bd51..e0488e0be390c4 100644 --- a/homeassistant/components/tradfri/manifest.json +++ b/homeassistant/components/tradfri/manifest.json @@ -7,6 +7,7 @@ "homekit": { "models": ["TRADFRI"] }, + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pytradfri"], "requirements": ["pytradfri[async]==9.0.1"] diff --git a/homeassistant/components/trafikverket_camera/manifest.json b/homeassistant/components/trafikverket_camera/manifest.json index 08d945e0a0c737..641654de20a0a0 100644 --- a/homeassistant/components/trafikverket_camera/manifest.json +++ b/homeassistant/components/trafikverket_camera/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@gjohansson-ST"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/trafikverket_camera", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], "requirements": ["pytrafikverket==1.1.1"] diff --git a/homeassistant/components/trafikverket_ferry/manifest.json b/homeassistant/components/trafikverket_ferry/manifest.json index 4177587db7e116..a1c55f9697840b 100644 --- a/homeassistant/components/trafikverket_ferry/manifest.json +++ b/homeassistant/components/trafikverket_ferry/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@gjohansson-ST"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/trafikverket_ferry", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], "requirements": ["pytrafikverket==1.1.1"] diff --git a/homeassistant/components/trafikverket_train/manifest.json b/homeassistant/components/trafikverket_train/manifest.json index 40f3a39a2bb377..a97fd5b8cb8522 100644 --- a/homeassistant/components/trafikverket_train/manifest.json +++ b/homeassistant/components/trafikverket_train/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@gjohansson-ST"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/trafikverket_train", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], "requirements": ["pytrafikverket==1.1.1"] diff --git a/homeassistant/components/trafikverket_weatherstation/manifest.json b/homeassistant/components/trafikverket_weatherstation/manifest.json index 3996379540f26e..c65bef540d41e9 100644 --- a/homeassistant/components/trafikverket_weatherstation/manifest.json +++ b/homeassistant/components/trafikverket_weatherstation/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@gjohansson-ST"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/trafikverket_weatherstation", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], "requirements": ["pytrafikverket==1.1.1"] diff --git a/homeassistant/components/trane/__init__.py b/homeassistant/components/trane/__init__.py new file mode 100644 index 00000000000000..95d5a301f12265 --- /dev/null +++ b/homeassistant/components/trane/__init__.py @@ -0,0 +1,65 @@ +"""Integration for Trane Local thermostats.""" + +from __future__ import annotations + +from steamloop import ( + AuthenticationError, + SteamloopConnectionError, + ThermostatConnection, +) + +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr + +from .const import CONF_SECRET_KEY, DOMAIN, MANUFACTURER +from .types import TraneConfigEntry + +PLATFORMS = [Platform.CLIMATE, Platform.SWITCH] + + +async def async_setup_entry(hass: HomeAssistant, entry: TraneConfigEntry) -> bool: + """Set up Trane Local from a config entry.""" + conn = ThermostatConnection( + entry.data[CONF_HOST], + secret_key=entry.data[CONF_SECRET_KEY], + ) + + try: + await conn.connect() + await conn.login() + except (SteamloopConnectionError, TimeoutError) as err: + await conn.disconnect() + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + except AuthenticationError as err: + await conn.disconnect() + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_failed", + ) from err + + conn.start_background_tasks() + entry.runtime_data = conn + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, entry.entry_id)}, + manufacturer=MANUFACTURER, + translation_key="thermostat", + translation_placeholders={"host": entry.data[CONF_HOST]}, + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: TraneConfigEntry) -> bool: + """Unload a Trane Local config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + await entry.runtime_data.disconnect() + return unload_ok diff --git a/homeassistant/components/trane/climate.py b/homeassistant/components/trane/climate.py new file mode 100644 index 00000000000000..b076236a44c7b4 --- /dev/null +++ b/homeassistant/components/trane/climate.py @@ -0,0 +1,200 @@ +"""Climate platform for the Trane Local integration.""" + +from __future__ import annotations + +from typing import Any + +from steamloop import FanMode, HoldType, ThermostatConnection, ZoneMode + +from homeassistant.components.climate import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import TraneZoneEntity +from .types import TraneConfigEntry + +PARALLEL_UPDATES = 0 + +HA_TO_ZONE_MODE = { + HVACMode.OFF: ZoneMode.OFF, + HVACMode.HEAT: ZoneMode.HEAT, + HVACMode.COOL: ZoneMode.COOL, + HVACMode.HEAT_COOL: ZoneMode.AUTO, + HVACMode.AUTO: ZoneMode.AUTO, +} + +ZONE_MODE_TO_HA = { + ZoneMode.OFF: HVACMode.OFF, + ZoneMode.HEAT: HVACMode.HEAT, + ZoneMode.COOL: HVACMode.COOL, + ZoneMode.AUTO: HVACMode.AUTO, +} + +HA_TO_FAN_MODE = { + "auto": FanMode.AUTO, + "on": FanMode.ALWAYS_ON, + "circulate": FanMode.CIRCULATE, +} + +FAN_MODE_TO_HA = {v: k for k, v in HA_TO_FAN_MODE.items()} + +SINGLE_SETPOINT_MODES = frozenset({ZoneMode.COOL, ZoneMode.HEAT}) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: TraneConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Trane Local climate entities.""" + conn = config_entry.runtime_data + async_add_entities( + TraneClimateEntity(conn, config_entry.entry_id, zone_id) + for zone_id in conn.state.zones + ) + + +class TraneClimateEntity(TraneZoneEntity, ClimateEntity): + """Climate entity for a Trane thermostat zone.""" + + _attr_name = None + _attr_translation_key = "zone" + _attr_fan_modes = list(HA_TO_FAN_MODE) + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + _attr_target_temperature_step = 1.0 + + def __init__(self, conn: ThermostatConnection, entry_id: str, zone_id: str) -> None: + """Initialize the climate entity.""" + super().__init__(conn, entry_id, zone_id, "zone") + modes: list[HVACMode] = [] + for zone_mode in conn.state.supported_modes: + ha_mode = ZONE_MODE_TO_HA.get(zone_mode) + if ha_mode is None: + continue + modes.append(ha_mode) + # AUTO in steamloop maps to both AUTO (schedule) and HEAT_COOL (manual hold) + if zone_mode == ZoneMode.AUTO: + modes.append(HVACMode.HEAT_COOL) + self._attr_hvac_modes = modes + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + # indoor_temperature is a string from the protocol (e.g. "72.00") + # or empty string if not yet received + if temp := self._zone.indoor_temperature: + return float(temp) + return None + + @property + def current_humidity(self) -> int | None: + """Return the current humidity.""" + # relative_humidity is a string from the protocol (e.g. "45") + # or empty string if not yet received + if humidity := self._conn.state.relative_humidity: + return int(humidity) + return None + + @property + def hvac_mode(self) -> HVACMode: + """Return the current HVAC mode.""" + zone = self._zone + if zone.mode == ZoneMode.AUTO and zone.hold_type == HoldType.MANUAL: + return HVACMode.HEAT_COOL + return ZONE_MODE_TO_HA.get(zone.mode, HVACMode.OFF) + + @property + def hvac_action(self) -> HVACAction: + """Return the current HVAC action.""" + # heating_active and cooling_active are system-level strings from the + # protocol ("0"=off, "1"=idle, "2"=running); filter by zone mode so + # a zone in COOL never reports HEATING and vice versa + zone_mode = self._zone.mode + if zone_mode == ZoneMode.OFF: + return HVACAction.OFF + state = self._conn.state + if zone_mode != ZoneMode.HEAT and state.cooling_active == "2": + return HVACAction.COOLING + if zone_mode != ZoneMode.COOL and state.heating_active == "2": + return HVACAction.HEATING + return HVACAction.IDLE + + @property + def target_temperature(self) -> float | None: + """Return target temperature for single-setpoint modes.""" + # Setpoints are strings from the protocol or empty string if not yet received + zone = self._zone + if zone.mode == ZoneMode.COOL: + return float(zone.cool_setpoint) if zone.cool_setpoint else None + if zone.mode == ZoneMode.HEAT: + return float(zone.heat_setpoint) if zone.heat_setpoint else None + return None + + @property + def target_temperature_high(self) -> float | None: + """Return the upper bound target temperature.""" + zone = self._zone + if zone.mode in SINGLE_SETPOINT_MODES: + return None + return float(zone.cool_setpoint) if zone.cool_setpoint else None + + @property + def target_temperature_low(self) -> float | None: + """Return the lower bound target temperature.""" + zone = self._zone + if zone.mode in SINGLE_SETPOINT_MODES: + return None + return float(zone.heat_setpoint) if zone.heat_setpoint else None + + @property + def fan_mode(self) -> str: + """Return the current fan mode.""" + return FAN_MODE_TO_HA.get(self._conn.state.fan_mode, "auto") + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the HVAC mode.""" + if hvac_mode == HVACMode.OFF: + self._conn.set_zone_mode(self._zone_id, ZoneMode.OFF) + return + + hold_type = HoldType.SCHEDULE if hvac_mode == HVACMode.AUTO else HoldType.MANUAL + self._conn.set_temperature_setpoint(self._zone_id, hold_type=hold_type) + + self._conn.set_zone_mode(self._zone_id, HA_TO_ZONE_MODE[hvac_mode]) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set target temperature.""" + heat_temp = kwargs.get(ATTR_TARGET_TEMP_LOW) + cool_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH) + set_temp = kwargs.get(ATTR_TEMPERATURE) + + if set_temp is not None: + if self._zone.mode == ZoneMode.COOL: + cool_temp = set_temp + elif self._zone.mode == ZoneMode.HEAT: + heat_temp = set_temp + + self._conn.set_temperature_setpoint( + self._zone_id, + heat_setpoint=str(round(heat_temp)) if heat_temp is not None else None, + cool_setpoint=str(round(cool_temp)) if cool_temp is not None else None, + ) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set the fan mode.""" + self._conn.set_fan_mode(HA_TO_FAN_MODE[fan_mode]) diff --git a/homeassistant/components/trane/config_flow.py b/homeassistant/components/trane/config_flow.py new file mode 100644 index 00000000000000..72477c375b551e --- /dev/null +++ b/homeassistant/components/trane/config_flow.py @@ -0,0 +1,60 @@ +"""Config flow for the Trane Local integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from steamloop import PairingError, SteamloopConnectionError, ThermostatConnection +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST + +from .const import CONF_SECRET_KEY, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + } +) + + +class TraneConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Trane Local.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step.""" + errors: dict[str, str] = {} + if user_input is not None: + host = user_input[CONF_HOST] + self._async_abort_entries_match({CONF_HOST: host}) + conn = ThermostatConnection(host, secret_key="") + try: + await conn.connect() + await conn.pair() + except SteamloopConnectionError, PairingError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception during pairing") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=f"Thermostat ({host})", + data={ + CONF_HOST: host, + CONF_SECRET_KEY: conn.secret_key, + }, + ) + finally: + await conn.disconnect() + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/trane/const.py b/homeassistant/components/trane/const.py new file mode 100644 index 00000000000000..8b5f29197af762 --- /dev/null +++ b/homeassistant/components/trane/const.py @@ -0,0 +1,7 @@ +"""Constants for the Trane Local integration.""" + +DOMAIN = "trane" + +CONF_SECRET_KEY = "secret_key" + +MANUFACTURER = "Trane" diff --git a/homeassistant/components/trane/entity.py b/homeassistant/components/trane/entity.py new file mode 100644 index 00000000000000..a6c27f33b9bfed --- /dev/null +++ b/homeassistant/components/trane/entity.py @@ -0,0 +1,67 @@ +"""Base entity for the Trane Local integration.""" + +from __future__ import annotations + +from typing import Any + +from steamloop import ThermostatConnection, Zone + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN, MANUFACTURER + + +class TraneEntity(Entity): + """Base class for all Trane entities.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, conn: ThermostatConnection) -> None: + """Initialize the entity.""" + self._conn = conn + + async def async_added_to_hass(self) -> None: + """Register event callback when added to hass.""" + self.async_on_remove(self._conn.add_event_callback(self._handle_event)) + + @callback + def _handle_event(self, _event: dict[str, Any]) -> None: + """Handle a thermostat event.""" + self.async_write_ha_state() + + +class TraneZoneEntity(TraneEntity): + """Base class for Trane zone-level entities.""" + + def __init__( + self, + conn: ThermostatConnection, + entry_id: str, + zone_id: str, + unique_id_suffix: str, + ) -> None: + """Initialize the entity.""" + super().__init__(conn) + self._zone_id = zone_id + self._attr_unique_id = f"{entry_id}_{zone_id}_{unique_id_suffix}" + zone_name = self._zone.name or f"Zone {zone_id}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{entry_id}_{zone_id}")}, + manufacturer=MANUFACTURER, + name=zone_name, + suggested_area=zone_name, + via_device=(DOMAIN, entry_id), + ) + + @property + def available(self) -> bool: + """Return True if the zone is available.""" + return self._zone_id in self._conn.state.zones + + @property + def _zone(self) -> Zone: + """Return the current zone state.""" + return self._conn.state.zones[self._zone_id] diff --git a/homeassistant/components/trane/icons.json b/homeassistant/components/trane/icons.json new file mode 100644 index 00000000000000..0101ebb754dce8 --- /dev/null +++ b/homeassistant/components/trane/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "switch": { + "hold": { + "default": "mdi:timer", + "state": { + "on": "mdi:timer-off" + } + } + } + } +} diff --git a/homeassistant/components/trane/manifest.json b/homeassistant/components/trane/manifest.json new file mode 100644 index 00000000000000..940fccef1fba58 --- /dev/null +++ b/homeassistant/components/trane/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "trane", + "name": "Trane Local", + "codeowners": ["@bdraco"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/trane", + "integration_type": "hub", + "iot_class": "local_push", + "loggers": ["steamloop"], + "quality_scale": "bronze", + "requirements": ["steamloop==1.2.0"] +} diff --git a/homeassistant/components/trane/quality_scale.yaml b/homeassistant/components/trane/quality_scale.yaml new file mode 100644 index 00000000000000..665d16b97dcbf6 --- /dev/null +++ b/homeassistant/components/trane/quality_scale.yaml @@ -0,0 +1,72 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom actions are defined. + appropriate-polling: + status: exempt + comment: | + This is a local push integration that uses event callbacks. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom actions are defined. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + No custom actions are defined. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: done + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: done + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/trane/strings.json b/homeassistant/components/trane/strings.json new file mode 100644 index 00000000000000..ec6ba97d65c7fb --- /dev/null +++ b/homeassistant/components/trane/strings.json @@ -0,0 +1,55 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The IP address of the thermostat" + }, + "description": "Put the thermostat in pairing mode (Menu > Settings > Network > Advanced Setup > Remote Connection > Pair). The thermostat must have a static IP address assigned." + } + } + }, + "device": { + "thermostat": { + "name": "Thermostat ({host})" + } + }, + "entity": { + "climate": { + "zone": { + "state_attributes": { + "fan_mode": { + "state": { + "auto": "[%key:common::state::auto%]", + "circulate": "Circulate", + "on": "[%key:common::state::on%]" + } + } + } + } + }, + "switch": { + "hold": { + "name": "Hold" + } + } + }, + "exceptions": { + "authentication_failed": { + "message": "Authentication failed with thermostat" + }, + "cannot_connect": { + "message": "Failed to connect to thermostat" + } + } +} diff --git a/homeassistant/components/trane/switch.py b/homeassistant/components/trane/switch.py new file mode 100644 index 00000000000000..a31b12cbd3d795 --- /dev/null +++ b/homeassistant/components/trane/switch.py @@ -0,0 +1,52 @@ +"""Switch platform for the Trane Local integration.""" + +from __future__ import annotations + +from typing import Any + +from steamloop import HoldType, ThermostatConnection + +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import TraneZoneEntity +from .types import TraneConfigEntry + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: TraneConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Trane Local switch entities.""" + conn = config_entry.runtime_data + async_add_entities( + TraneHoldSwitch(conn, config_entry.entry_id, zone_id) + for zone_id in conn.state.zones + ) + + +class TraneHoldSwitch(TraneZoneEntity, SwitchEntity): + """Switch to control the hold mode of a thermostat zone.""" + + _attr_translation_key = "hold" + + def __init__(self, conn: ThermostatConnection, entry_id: str, zone_id: str) -> None: + """Initialize the hold switch.""" + super().__init__(conn, entry_id, zone_id, "hold") + + @property + def is_on(self) -> bool: + """Return true if the zone is in permanent hold.""" + return self._zone.hold_type == HoldType.MANUAL + + async def async_turn_on(self, **kwargs: Any) -> None: + """Enable permanent hold.""" + self._conn.set_temperature_setpoint(self._zone_id, hold_type=HoldType.MANUAL) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Return to schedule.""" + self._conn.set_temperature_setpoint(self._zone_id, hold_type=HoldType.SCHEDULE) diff --git a/homeassistant/components/trane/types.py b/homeassistant/components/trane/types.py new file mode 100644 index 00000000000000..bbfa68a271f964 --- /dev/null +++ b/homeassistant/components/trane/types.py @@ -0,0 +1,7 @@ +"""Types for the Trane Local integration.""" + +from steamloop import ThermostatConnection + +from homeassistant.config_entries import ConfigEntry + +type TraneConfigEntry = ConfigEntry[ThermostatConnection] diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index d5a566879a66e9..56d6a2d5d67a02 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -36,7 +36,6 @@ from .const import DEFAULT_PATH, DEFAULT_SSL, DOMAIN from .coordinator import TransmissionConfigEntry, TransmissionDataUpdateCoordinator -from .errors import AuthenticationError, CannotConnect, UnknownError from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -93,10 +92,10 @@ def update_unique_id( try: api = await get_api(hass, dict(config_entry.data)) - except CannotConnect as error: - raise ConfigEntryNotReady from error - except (AuthenticationError, UnknownError) as error: - raise ConfigEntryAuthFailed from error + except TransmissionAuthError as err: + raise ConfigEntryAuthFailed from err + except (TransmissionConnectError, TransmissionError) as err: + raise ConfigEntryNotReady from err protocol: Final = "https" if config_entry.data[CONF_SSL] else "http" device_registry = dr.async_get(hass) @@ -171,26 +170,17 @@ async def get_api( username = entry.get(CONF_USERNAME) password = entry.get(CONF_PASSWORD) - try: - api = await hass.async_add_executor_job( - partial( - transmission_rpc.Client, - username=username, - password=password, - protocol=protocol, - host=host, - port=port, - path=path, - ) + api = await hass.async_add_executor_job( + partial( + transmission_rpc.Client, + username=username, + password=password, + protocol=protocol, + host=host, + port=port, + path=path, ) - except TransmissionAuthError as error: - _LOGGER.error("Credentials for Transmission client are not valid") - raise AuthenticationError from error - except TransmissionConnectError as error: - _LOGGER.error("Connecting to the Transmission client %s failed", host) - raise CannotConnect from error - except TransmissionError as error: - _LOGGER.error(error) - raise UnknownError from error + ) + _LOGGER.debug("Successfully connected to %s", host) return api diff --git a/homeassistant/components/transmission/config_flow.py b/homeassistant/components/transmission/config_flow.py index 467a2ce55487b4..9294319aeb8806 100644 --- a/homeassistant/components/transmission/config_flow.py +++ b/homeassistant/components/transmission/config_flow.py @@ -5,6 +5,11 @@ from collections.abc import Mapping from typing import Any +from transmission_rpc.error import ( + TransmissionAuthError, + TransmissionConnectError, + TransmissionError, +) import voluptuous as vol from homeassistant.config_entries import ( @@ -37,7 +42,6 @@ DOMAIN, SUPPORTED_ORDER_MODES, ) -from .errors import AuthenticationError, CannotConnect, UnknownError DATA_SCHEMA = vol.Schema( { @@ -78,10 +82,10 @@ async def async_step_user( try: await get_api(self.hass, user_input) - except AuthenticationError: + except TransmissionAuthError: errors[CONF_USERNAME] = "invalid_auth" errors[CONF_PASSWORD] = "invalid_auth" - except CannotConnect, UnknownError: + except TransmissionConnectError, TransmissionError: errors["base"] = "cannot_connect" if not errors: @@ -113,9 +117,9 @@ async def async_step_reauth_confirm( try: await get_api(self.hass, user_input) - except AuthenticationError: + except TransmissionAuthError: errors[CONF_PASSWORD] = "invalid_auth" - except CannotConnect, UnknownError: + except TransmissionConnectError, TransmissionError: errors["base"] = "cannot_connect" else: return self.async_update_reload_and_abort(reauth_entry, data=user_input) diff --git a/homeassistant/components/transmission/errors.py b/homeassistant/components/transmission/errors.py deleted file mode 100644 index 68d442c3a74480..00000000000000 --- a/homeassistant/components/transmission/errors.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Errors for the Transmission component.""" - -from homeassistant.exceptions import HomeAssistantError - - -class AuthenticationError(HomeAssistantError): - """Wrong Username or Password.""" - - -class CannotConnect(HomeAssistantError): - """Unable to connect to client.""" - - -class UnknownError(HomeAssistantError): - """Unknown Error.""" diff --git a/homeassistant/components/transport_nsw/sensor.py b/homeassistant/components/transport_nsw/sensor.py index 49a11a57f65b08..1f247a0c699dd1 100644 --- a/homeassistant/components/transport_nsw/sensor.py +++ b/homeassistant/components/transport_nsw/sensor.py @@ -78,25 +78,16 @@ class TransportNSWSensor(SensorEntity): _attr_attribution = "Data provided by Transport NSW" _attr_device_class = SensorDeviceClass.DURATION + _attr_native_unit_of_measurement = UnitOfTime.MINUTES _attr_state_class = SensorStateClass.MEASUREMENT def __init__(self, data, stop_id, name): """Initialize the sensor.""" self.data = data - self._name = name + self._attr_name = name self._stop_id = stop_id - self._times = self._state = None - self._icon = ICONS[None] - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state + self._times = None + self._attr_icon = ICONS[None] @property def extra_state_attributes(self) -> dict[str, Any] | None: @@ -113,22 +104,12 @@ def extra_state_attributes(self) -> dict[str, Any] | None: } return None - @property - def native_unit_of_measurement(self): - """Return the unit this state is expressed in.""" - return UnitOfTime.MINUTES - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon - def update(self) -> None: """Get the latest data from Transport NSW and update the states.""" self.data.update() self._times = self.data.info - self._state = self._times[ATTR_DUE_IN] - self._icon = ICONS[self._times[ATTR_MODE]] + self._attr_native_value = self._times[ATTR_DUE_IN] + self._attr_icon = ICONS[self._times[ATTR_MODE]] def _get_value(value): diff --git a/homeassistant/components/travisci/sensor.py b/homeassistant/components/travisci/sensor.py index 8193c5a67dc74e..9644016b90a68a 100644 --- a/homeassistant/components/travisci/sensor.py +++ b/homeassistant/components/travisci/sensor.py @@ -4,6 +4,7 @@ from datetime import timedelta import logging +from typing import Any from travispy import TravisPy from travispy.errors import TravisError @@ -154,9 +155,9 @@ def __init__( self._attr_name = f"{repo_name} {description.name}" @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - attrs = {} + attrs: dict[str, Any] = {} if self._build and self._attr_native_value is not None: if self._user and self.entity_description.key == "state": diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index 5a7046c2125d8c..c0b24a4fbde550 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -21,7 +21,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, CONF_ATTRIBUTE, CONF_DEVICE_CLASS, CONF_ENTITY_ID, @@ -209,7 +208,6 @@ def extra_state_attributes(self) -> Mapping[str, Any]: """Return the state attributes of the sensor.""" return { ATTR_ENTITY_ID: self._entity_id, - ATTR_FRIENDLY_NAME: self._attr_name, ATTR_GRADIENT: self._gradient, ATTR_INVERT: self._invert, ATTR_MIN_GRADIENT: self._min_gradient, diff --git a/homeassistant/components/trmnl/__init__.py b/homeassistant/components/trmnl/__init__.py new file mode 100644 index 00000000000000..497a398e301085 --- /dev/null +++ b/homeassistant/components/trmnl/__init__.py @@ -0,0 +1,29 @@ +"""The TRMNL integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import TRMNLConfigEntry, TRMNLCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH, Platform.TIME] + + +async def async_setup_entry(hass: HomeAssistant, entry: TRMNLConfigEntry) -> bool: + """Set up TRMNL from a config entry.""" + + coordinator = TRMNLCoordinator(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: TRMNLConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/trmnl/config_flow.py b/homeassistant/components/trmnl/config_flow.py new file mode 100644 index 00000000000000..828bc1a3ad4796 --- /dev/null +++ b/homeassistant/components/trmnl/config_flow.py @@ -0,0 +1,84 @@ +"""Config flow for TRMNL.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from trmnl import TRMNLClient +from trmnl.exceptions import TRMNLAuthenticationError, TRMNLError +import voluptuous as vol + +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, +) +from homeassistant.const import CONF_API_KEY +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, LOGGER + +STEP_USER_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str}) + +TRMNL_ACCOUNT_URL = "https://trmnl.com/account" + + +class TRMNLConfigFlow(ConfigFlow, domain=DOMAIN): + """TRMNL config flow.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user, reauth, or reconfigure.""" + errors: dict[str, str] = {} + if user_input: + session = async_get_clientsession(self.hass) + client = TRMNLClient(token=user_input[CONF_API_KEY], session=session) + try: + user = await client.get_me() + except TRMNLAuthenticationError: + errors["base"] = "invalid_auth" + except TRMNLError: + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected error") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(str(user.identifier)) + if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={CONF_API_KEY: user_input[CONF_API_KEY]}, + ) + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates={CONF_API_KEY: user_input[CONF_API_KEY]}, + ) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user.name, + data={CONF_API_KEY: user_input[CONF_API_KEY]}, + ) + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_SCHEMA, + errors=errors, + description_placeholders={"account_url": TRMNL_ACCOUNT_URL}, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_user() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + return await self.async_step_user() diff --git a/homeassistant/components/trmnl/const.py b/homeassistant/components/trmnl/const.py new file mode 100644 index 00000000000000..15124feba0e933 --- /dev/null +++ b/homeassistant/components/trmnl/const.py @@ -0,0 +1,7 @@ +"""Constants for the TRMNL integration.""" + +import logging + +DOMAIN = "trmnl" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/trmnl/coordinator.py b/homeassistant/components/trmnl/coordinator.py new file mode 100644 index 00000000000000..f66582150c1411 --- /dev/null +++ b/homeassistant/components/trmnl/coordinator.py @@ -0,0 +1,69 @@ +"""Define an object to manage fetching TRMNL data.""" + +from __future__ import annotations + +from datetime import timedelta + +from trmnl import TRMNLClient +from trmnl.exceptions import TRMNLAuthenticationError, TRMNLError +from trmnl.models import Device + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +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_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + +type TRMNLConfigEntry = ConfigEntry[TRMNLCoordinator] + + +class TRMNLCoordinator(DataUpdateCoordinator[dict[int, Device]]): + """Class to manage fetching TRMNL data.""" + + config_entry: TRMNLConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: TRMNLConfigEntry) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + logger=LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=timedelta(hours=1), + ) + self.client = TRMNLClient( + token=config_entry.data[CONF_API_KEY], + session=async_get_clientsession(hass), + ) + + async def _async_update_data(self) -> dict[int, Device]: + """Fetch data from TRMNL.""" + try: + devices = await self.client.get_devices() + except TRMNLAuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_error", + ) from err + except TRMNLError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={"error": str(err)}, + ) from err + new_data = {device.identifier: device for device in devices} + if self.data is not None: + device_registry = dr.async_get(self.hass) + for device_id in set(self.data) - set(new_data): + if entry := device_registry.async_get_device( + identifiers={(DOMAIN, str(device_id))} + ): + device_registry.async_update_device( + device_id=entry.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + return new_data diff --git a/homeassistant/components/trmnl/diagnostics.py b/homeassistant/components/trmnl/diagnostics.py new file mode 100644 index 00000000000000..53f215185afd5f --- /dev/null +++ b/homeassistant/components/trmnl/diagnostics.py @@ -0,0 +1,25 @@ +"""Diagnostics support for TRMNL.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from .coordinator import TRMNLConfigEntry + +TO_REDACT = {"mac_address"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: TRMNLConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return { + "data": [ + async_redact_data(asdict(device), TO_REDACT) + for device in entry.runtime_data.data.values() + ], + } diff --git a/homeassistant/components/trmnl/entity.py b/homeassistant/components/trmnl/entity.py new file mode 100644 index 00000000000000..744028366d67a6 --- /dev/null +++ b/homeassistant/components/trmnl/entity.py @@ -0,0 +1,65 @@ +"""Base class for TRMNL entities.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from typing import Any, Concatenate + +from trmnl.exceptions import TRMNLError +from trmnl.models import Device + +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import TRMNLCoordinator + + +class TRMNLEntity(CoordinatorEntity[TRMNLCoordinator]): + """Defines a base TRMNL entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: TRMNLCoordinator, device_id: int) -> None: + """Initialize TRMNL entity.""" + super().__init__(coordinator) + self._device_id = device_id + device = self._device + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, device.mac_address)}, + identifiers={(DOMAIN, str(device_id))}, + name=device.name, + manufacturer="TRMNL", + ) + + @property + def _device(self) -> Device: + """Return the device from coordinator data.""" + return self.coordinator.data[self._device_id] + + @property + def available(self) -> bool: + """Return if the device is available.""" + return super().available and self._device_id in self.coordinator.data + + +def exception_handler[_EntityT: TRMNLEntity, **_P]( + func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: + """Decorate TRMNL calls to handle exceptions. + + A decorator that wraps the passed in function, catches TRMNL errors. + """ + + async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: + try: + await func(self, *args, **kwargs) + except TRMNLError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="action_error", + translation_placeholders={"error": str(error)}, + ) from error + + return handler diff --git a/homeassistant/components/trmnl/icons.json b/homeassistant/components/trmnl/icons.json new file mode 100644 index 00000000000000..853581ffe2a0cc --- /dev/null +++ b/homeassistant/components/trmnl/icons.json @@ -0,0 +1,31 @@ +{ + "entity": { + "sensor": { + "wifi_strength": { + "default": "mdi:wifi-strength-off-outline", + "range": { + "0": "mdi:wifi-strength-1", + "25": "mdi:wifi-strength-2", + "50": "mdi:wifi-strength-3", + "75": "mdi:wifi-strength-4" + } + } + }, + "switch": { + "sleep_mode": { + "default": "mdi:sleep-off", + "state": { + "on": "mdi:sleep" + } + } + }, + "time": { + "sleep_end_time": { + "default": "mdi:sleep-off" + }, + "sleep_start_time": { + "default": "mdi:sleep" + } + } + } +} diff --git a/homeassistant/components/trmnl/manifest.json b/homeassistant/components/trmnl/manifest.json new file mode 100644 index 00000000000000..bdc2056f513417 --- /dev/null +++ b/homeassistant/components/trmnl/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "trmnl", + "name": "TRMNL", + "codeowners": ["@joostlek"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/trmnl", + "integration_type": "hub", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["trmnl==0.1.1"] +} diff --git a/homeassistant/components/trmnl/quality_scale.yaml b/homeassistant/components/trmnl/quality_scale.yaml new file mode 100644 index 00000000000000..ed08cbc49af43b --- /dev/null +++ b/homeassistant/components/trmnl/quality_scale.yaml @@ -0,0 +1,74 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Entities of this integration do not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: There are no configuration parameters + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: Uses the cloud API + discovery: + status: exempt + comment: Can't be discovered + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: 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: There are no repairable issues + stale-devices: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/trmnl/sensor.py b/homeassistant/components/trmnl/sensor.py new file mode 100644 index 00000000000000..ba73b3fbad1955 --- /dev/null +++ b/homeassistant/components/trmnl/sensor.py @@ -0,0 +1,122 @@ +"""Support for TRMNL sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from trmnl.models import Device + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfElectricPotential, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import TRMNLConfigEntry +from .coordinator import TRMNLCoordinator +from .entity import TRMNLEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class TRMNLSensorEntityDescription(SensorEntityDescription): + """Describes a TRMNL sensor entity.""" + + value_fn: Callable[[Device], int | float | None] + + +SENSOR_DESCRIPTIONS: tuple[TRMNLSensorEntityDescription, ...] = ( + TRMNLSensorEntityDescription( + key="battery", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda device: device.percent_charged, + ), + TRMNLSensorEntityDescription( + key="battery_voltage", + translation_key="battery_voltage", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda device: device.battery_voltage, + ), + TRMNLSensorEntityDescription( + key="rssi", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda device: device.rssi, + ), + TRMNLSensorEntityDescription( + key="wifi_strength", + translation_key="wifi_strength", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda device: device.wifi_strength, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TRMNLConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up TRMNL sensor entities based on a config entry.""" + coordinator = entry.runtime_data + + known_device_ids: set[int] = set() + + def _async_entity_listener() -> None: + new_ids = set(coordinator.data) - known_device_ids + if new_ids: + async_add_entities( + TRMNLSensor(coordinator, device_id, description) + for device_id in new_ids + for description in SENSOR_DESCRIPTIONS + ) + known_device_ids.update(new_ids) + + entry.async_on_unload(coordinator.async_add_listener(_async_entity_listener)) + _async_entity_listener() + + +class TRMNLSensor(TRMNLEntity, SensorEntity): + """Defines a TRMNL sensor.""" + + entity_description: TRMNLSensorEntityDescription + + def __init__( + self, + coordinator: TRMNLCoordinator, + device_id: int, + description: TRMNLSensorEntityDescription, + ) -> None: + """Initialize TRMNL sensor.""" + super().__init__(coordinator, device_id) + self.entity_description = description + self._attr_unique_id = f"{device_id}_{description.key}" + + @property + def native_value(self) -> int | float | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self._device) diff --git a/homeassistant/components/trmnl/strings.json b/homeassistant/components/trmnl/strings.json new file mode 100644 index 00000000000000..250a11951572a4 --- /dev/null +++ b/homeassistant/components/trmnl/strings.json @@ -0,0 +1,63 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "The API key belongs to a different account. Please use the API key for the original account." + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "initiate_flow": { + "user": "[%key:common::config_flow::initiate_flow::account%]" + }, + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "The API key for your TRMNL account." + }, + "description": "You can find your API key on [your TRMNL account page]({account_url})." + } + } + }, + "entity": { + "sensor": { + "battery_voltage": { + "name": "Battery voltage" + }, + "wifi_strength": { + "name": "Wi-Fi strength" + } + }, + "switch": { + "sleep_mode": { + "name": "Sleep mode" + } + }, + "time": { + "sleep_end_time": { + "name": "Sleep end time" + }, + "sleep_start_time": { + "name": "Sleep start time" + } + } + }, + "exceptions": { + "action_error": { + "message": "An error occurred while communicating with TRMNL: {error}" + }, + "authentication_error": { + "message": "Authentication failed. Please check your API key." + }, + "update_error": { + "message": "An error occurred while communicating with TRMNL: {error}" + } + } +} diff --git a/homeassistant/components/trmnl/switch.py b/homeassistant/components/trmnl/switch.py new file mode 100644 index 00000000000000..78438826985010 --- /dev/null +++ b/homeassistant/components/trmnl/switch.py @@ -0,0 +1,103 @@ +"""Support for TRMNL switch entities.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from trmnl.models import Device + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import TRMNLConfigEntry +from .coordinator import TRMNLCoordinator +from .entity import TRMNLEntity, exception_handler + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class TRMNLSwitchEntityDescription(SwitchEntityDescription): + """Describes a TRMNL switch entity.""" + + value_fn: Callable[[Device], bool] + set_value_fn: Callable[[TRMNLCoordinator, int, bool], Coroutine[Any, Any, None]] + + +SWITCH_DESCRIPTIONS: tuple[TRMNLSwitchEntityDescription, ...] = ( + TRMNLSwitchEntityDescription( + key="sleep_mode", + translation_key="sleep_mode", + entity_category=EntityCategory.CONFIG, + value_fn=lambda device: device.sleep_mode_enabled, + set_value_fn=lambda coordinator, device_id, value: ( + coordinator.client.update_device(device_id, sleep_mode_enabled=value) + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TRMNLConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up TRMNL switch entities based on a config entry.""" + coordinator = entry.runtime_data + + known_device_ids: set[int] = set() + + def _async_entity_listener() -> None: + new_ids = set(coordinator.data) - known_device_ids + if new_ids: + async_add_entities( + TRMNLSwitchEntity(coordinator, device_id, description) + for device_id in new_ids + for description in SWITCH_DESCRIPTIONS + ) + known_device_ids.update(new_ids) + + entry.async_on_unload(coordinator.async_add_listener(_async_entity_listener)) + _async_entity_listener() + + +class TRMNLSwitchEntity(TRMNLEntity, SwitchEntity): + """Defines a TRMNL switch entity.""" + + entity_description: TRMNLSwitchEntityDescription + + def __init__( + self, + coordinator: TRMNLCoordinator, + device_id: int, + description: TRMNLSwitchEntityDescription, + ) -> None: + """Initialize TRMNL switch entity.""" + super().__init__(coordinator, device_id) + self.entity_description = description + self._attr_unique_id = f"{device_id}_{description.key}" + + @property + def is_on(self) -> bool: + """Return if sleep mode is enabled.""" + return self.entity_description.value_fn(self._device) + + @exception_handler + async def async_turn_on(self, **kwargs: Any) -> None: + """Enable sleep mode.""" + await self.entity_description.set_value_fn( + self.coordinator, self._device_id, True + ) + await self.coordinator.async_request_refresh() + + @exception_handler + async def async_turn_off(self, **kwargs: Any) -> None: + """Disable sleep mode.""" + await self.entity_description.set_value_fn( + self.coordinator, self._device_id, False + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/trmnl/time.py b/homeassistant/components/trmnl/time.py new file mode 100644 index 00000000000000..52dc7de5f02df1 --- /dev/null +++ b/homeassistant/components/trmnl/time.py @@ -0,0 +1,119 @@ +"""Support for TRMNL time entities.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from datetime import time +from typing import Any + +from trmnl.models import Device + +from homeassistant.components.time import TimeEntity, TimeEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import TRMNLConfigEntry +from .coordinator import TRMNLCoordinator +from .entity import TRMNLEntity, exception_handler + +PARALLEL_UPDATES = 0 + + +def _minutes_to_time(minutes: int) -> time: + """Convert minutes since midnight to a time object.""" + return time(hour=minutes // 60, minute=minutes % 60) + + +def _time_to_minutes(value: time) -> int: + """Convert a time object to minutes since midnight.""" + return value.hour * 60 + value.minute + + +@dataclass(frozen=True, kw_only=True) +class TRMNLTimeEntityDescription(TimeEntityDescription): + """Describes a TRMNL time entity.""" + + value_fn: Callable[[Device], time] + set_value_fn: Callable[[TRMNLCoordinator, int, time], Coroutine[Any, Any, None]] + + +TIME_DESCRIPTIONS: tuple[TRMNLTimeEntityDescription, ...] = ( + TRMNLTimeEntityDescription( + key="sleep_start_time", + translation_key="sleep_start_time", + entity_category=EntityCategory.CONFIG, + value_fn=lambda device: _minutes_to_time(device.sleep_start_time), + set_value_fn=lambda coordinator, device_id, value: ( + coordinator.client.update_device( + device_id, sleep_start_time=_time_to_minutes(value) + ) + ), + ), + TRMNLTimeEntityDescription( + key="sleep_end_time", + translation_key="sleep_end_time", + entity_category=EntityCategory.CONFIG, + value_fn=lambda device: _minutes_to_time(device.sleep_end_time), + set_value_fn=lambda coordinator, device_id, value: ( + coordinator.client.update_device( + device_id, sleep_end_time=_time_to_minutes(value) + ) + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TRMNLConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up TRMNL time entities based on a config entry.""" + coordinator = entry.runtime_data + + known_device_ids: set[int] = set() + + def _async_entity_listener() -> None: + new_ids = set(coordinator.data) - known_device_ids + if new_ids: + async_add_entities( + TRMNLTimeEntity(coordinator, device_id, description) + for device_id in new_ids + for description in TIME_DESCRIPTIONS + ) + known_device_ids.update(new_ids) + + entry.async_on_unload(coordinator.async_add_listener(_async_entity_listener)) + _async_entity_listener() + + +class TRMNLTimeEntity(TRMNLEntity, TimeEntity): + """Defines a TRMNL time entity.""" + + entity_description: TRMNLTimeEntityDescription + + def __init__( + self, + coordinator: TRMNLCoordinator, + device_id: int, + description: TRMNLTimeEntityDescription, + ) -> None: + """Initialize TRMNL time entity.""" + super().__init__(coordinator, device_id) + self.entity_description = description + self._attr_unique_id = f"{device_id}_{description.key}" + + @property + def native_value(self) -> time: + """Return the current time value.""" + return self.entity_description.value_fn(self._device) + + @exception_handler + async def async_set_value(self, value: time) -> None: + """Set the time value.""" + await self.entity_description.set_value_fn( + self.coordinator, self._device_id, value + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 3645afedd6d778..fb9dfcac13cc4b 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -527,6 +527,8 @@ def async_set_message(self, message: str) -> None: This method will leverage a disk cache to speed up generation. """ + if self._result_cache.done(): + return self._result_cache.set_result( self._manager.async_cache_message_in_memory( engine=self.engine, @@ -543,6 +545,8 @@ def async_set_message_stream(self, message_stream: AsyncGenerator[str]) -> None: This method can result in faster first byte when generating long responses. """ + if self._result_cache.done(): + return self._result_cache.set_result( self._manager.async_cache_message_stream_in_memory( engine=self.engine, diff --git a/homeassistant/components/tts/entity.py b/homeassistant/components/tts/entity.py index 77abaa26baba5c..3b5f29bba4449f 100644 --- a/homeassistant/components/tts/entity.py +++ b/homeassistant/components/tts/entity.py @@ -11,7 +11,7 @@ ATTR_MEDIA_ANNOUNCE, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, - DOMAIN as DOMAIN_MP, + DOMAIN as MP_DOMAIN, SERVICE_PLAY_MEDIA, MediaType, ) @@ -134,7 +134,7 @@ async def async_speak( ) -> None: """Speak via a Media Player.""" await self.hass.services.async_call( - DOMAIN_MP, + MP_DOMAIN, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: media_player_entity_id, diff --git a/homeassistant/components/tts/legacy.py b/homeassistant/components/tts/legacy.py index c3d7eb6fdd65b5..edae942a1d472a 100644 --- a/homeassistant/components/tts/legacy.py +++ b/homeassistant/components/tts/legacy.py @@ -15,7 +15,7 @@ ATTR_MEDIA_ANNOUNCE, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, - DOMAIN as DOMAIN_MP, + DOMAIN as MP_DOMAIN, SERVICE_PLAY_MEDIA, MediaType, ) @@ -153,7 +153,7 @@ async def async_say_handle(service: ServiceCall) -> None: entity_ids = service.data[ATTR_ENTITY_ID] await hass.services.async_call( - DOMAIN_MP, + MP_DOMAIN, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: entity_ids, diff --git a/homeassistant/components/tts/media_source.py b/homeassistant/components/tts/media_source.py index 4ff4f93d9cda5d..df336c5d76dfae 100644 --- a/homeassistant/components/tts/media_source.py +++ b/homeassistant/components/tts/media_source.py @@ -214,7 +214,7 @@ def _engine_item(self, engine: str, params: str | None = None) -> BrowseMediaSou media_class=MediaClass.APP, media_content_type="provider", title=engine_instance.name, - thumbnail=f"https://brands.home-assistant.io/_/{engine_domain}/logo.png", + thumbnail=f"/api/brands/integration/{engine_domain}/logo.png", can_play=False, can_expand=True, ) diff --git a/homeassistant/components/tuya/alarm_control_panel.py b/homeassistant/components/tuya/alarm_control_panel.py index 9a317a50e859b2..edfec02e94364b 100644 --- a/homeassistant/components/tuya/alarm_control_panel.py +++ b/homeassistant/components/tuya/alarm_control_panel.py @@ -2,9 +2,17 @@ from __future__ import annotations -from base64 import b64decode -from typing import Any - +from tuya_device_handlers.device_wrapper.alarm_control_panel import ( + AlarmActionWrapper, + AlarmChangedByWrapper, + AlarmStateWrapper, +) +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.helpers.homeassistant import ( + TuyaAlarmControlPanelAction, + TuyaAlarmControlPanelState, +) +from tuya_device_handlers.type_information import EnumTypeInformation from tuya_sharing import CustomerDevice, Manager from homeassistant.components.alarm_control_panel import ( @@ -20,8 +28,6 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -from .models import DeviceWrapper, DPCodeEnumWrapper, DPCodeRawWrapper -from .type_information import EnumTypeInformation ALARM: dict[DeviceCategory, tuple[AlarmControlPanelEntityDescription, ...]] = { DeviceCategory.MAL: ( @@ -29,87 +35,27 @@ key=DPCode.MASTER_MODE, name="Alarm", ), - ) + ), + DeviceCategory.WG2: ( + AlarmControlPanelEntityDescription( + key=DPCode.MASTER_MODE, + name="Alarm", + ), + ), } - -class _AlarmChangedByWrapper(DPCodeRawWrapper): - """Wrapper for changed_by. - - Decode base64 to utf-16be string, but only if alarm has been triggered. - """ - - def read_device_status(self, device: CustomerDevice) -> str | None: - """Read the device status.""" - if ( - device.status.get(DPCode.MASTER_STATE) != "alarm" - or (status := super().read_device_status(device)) is None - ): - return None - return status.decode("utf-16be") - - -class _AlarmStateWrapper(DPCodeEnumWrapper): - """Wrapper for the alarm state of a device. - - Handles alarm mode enum values and determines the alarm state, - including logic for detecting when the alarm is triggered and - distinguishing triggered state from battery warnings. - """ - - _STATE_MAPPINGS = { - # Tuya device mode => Home Assistant panel state - "disarmed": AlarmControlPanelState.DISARMED, - "arm": AlarmControlPanelState.ARMED_AWAY, - "home": AlarmControlPanelState.ARMED_HOME, - "sos": AlarmControlPanelState.TRIGGERED, - } - - def read_device_status( - self, device: CustomerDevice - ) -> AlarmControlPanelState | None: - """Read the device status.""" - # When the alarm is triggered, only its 'state' is changing. From 'normal' to 'alarm'. - # The 'mode' doesn't change, and stays as 'arm' or 'home'. - if device.status.get(DPCode.MASTER_STATE) == "alarm": - # Only report as triggered if NOT a battery warning - if not ( - (encoded_msg := device.status.get(DPCode.ALARM_MSG)) - and (decoded_message := b64decode(encoded_msg).decode("utf-16be")) - and "Sensor Low Battery" in decoded_message - ): - return AlarmControlPanelState.TRIGGERED - - if (status := super().read_device_status(device)) is None: - return None - return self._STATE_MAPPINGS.get(status) - - -class _AlarmActionWrapper(DPCodeEnumWrapper): - """Wrapper for setting the alarm mode of a device.""" - - _ACTION_MAPPINGS = { - # Home Assistant action => Tuya device mode - "arm_home": "home", - "arm_away": "arm", - "disarm": "disarmed", - "trigger": "sos", - } - - def __init__(self, dpcode: str, type_information: EnumTypeInformation) -> None: - """Init _AlarmActionWrapper.""" - super().__init__(dpcode, type_information) - self.options = [ - ha_action - for ha_action, tuya_action in self._ACTION_MAPPINGS.items() - if tuya_action in type_information.range - ] - - def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any: - """Convert value to raw value.""" - if value in self.options: - return self._ACTION_MAPPINGS[value] - raise ValueError(f"Unsupported value {value} for {self.dpcode}") +_TUYA_TO_HA_STATE_MAPPINGS = { + TuyaAlarmControlPanelState.DISARMED: AlarmControlPanelState.DISARMED, + TuyaAlarmControlPanelState.ARMED_HOME: AlarmControlPanelState.ARMED_HOME, + TuyaAlarmControlPanelState.ARMED_AWAY: AlarmControlPanelState.ARMED_AWAY, + TuyaAlarmControlPanelState.ARMED_NIGHT: AlarmControlPanelState.ARMED_NIGHT, + TuyaAlarmControlPanelState.ARMED_VACATION: AlarmControlPanelState.ARMED_VACATION, + TuyaAlarmControlPanelState.ARMED_CUSTOM_BYPASS: AlarmControlPanelState.ARMED_CUSTOM_BYPASS, + TuyaAlarmControlPanelState.PENDING: AlarmControlPanelState.PENDING, + TuyaAlarmControlPanelState.ARMING: AlarmControlPanelState.ARMING, + TuyaAlarmControlPanelState.DISARMING: AlarmControlPanelState.DISARMING, + TuyaAlarmControlPanelState.TRIGGERED: AlarmControlPanelState.TRIGGERED, +} async def async_setup_entry( @@ -132,13 +78,13 @@ def async_discover_device(device_ids: list[str]) -> None: device, manager, description, - action_wrapper=_AlarmActionWrapper( + action_wrapper=AlarmActionWrapper( master_mode.dpcode, master_mode ), - changed_by_wrapper=_AlarmChangedByWrapper.find_dpcode( + changed_by_wrapper=AlarmChangedByWrapper.find_dpcode( device, DPCode.ALARM_MSG ), - state_wrapper=_AlarmStateWrapper( + state_wrapper=AlarmStateWrapper( master_mode.dpcode, master_mode ), ) @@ -170,9 +116,9 @@ def __init__( device_manager: Manager, description: AlarmControlPanelEntityDescription, *, - action_wrapper: DeviceWrapper[str], + action_wrapper: DeviceWrapper[TuyaAlarmControlPanelAction], changed_by_wrapper: DeviceWrapper[str] | None, - state_wrapper: DeviceWrapper[AlarmControlPanelState], + state_wrapper: DeviceWrapper[TuyaAlarmControlPanelState], ) -> None: """Init Tuya Alarm.""" super().__init__(device, device_manager) @@ -183,17 +129,18 @@ def __init__( self._state_wrapper = state_wrapper # Determine supported modes - if "arm_home" in action_wrapper.options: + if TuyaAlarmControlPanelAction.ARM_HOME in action_wrapper.options: self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME - if "arm_away" in action_wrapper.options: + if TuyaAlarmControlPanelAction.ARM_AWAY in action_wrapper.options: self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY - if "trigger" in action_wrapper.options: + if TuyaAlarmControlPanelAction.TRIGGER in action_wrapper.options: self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER @property def alarm_state(self) -> AlarmControlPanelState | None: """Return the state of the device.""" - return self._read_wrapper(self._state_wrapper) + tuya_value = self._read_wrapper(self._state_wrapper) + return _TUYA_TO_HA_STATE_MAPPINGS.get(tuya_value) if tuya_value else None @property def changed_by(self) -> str | None: @@ -202,16 +149,24 @@ def changed_by(self) -> str | None: async def async_alarm_disarm(self, code: str | None = None) -> None: """Send Disarm command.""" - await self._async_send_wrapper_updates(self._action_wrapper, "disarm") + await self._async_send_wrapper_updates( + self._action_wrapper, TuyaAlarmControlPanelAction.DISARM + ) async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send Home command.""" - await self._async_send_wrapper_updates(self._action_wrapper, "arm_home") + await self._async_send_wrapper_updates( + self._action_wrapper, TuyaAlarmControlPanelAction.ARM_HOME + ) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send Arm command.""" - await self._async_send_wrapper_updates(self._action_wrapper, "arm_away") + await self._async_send_wrapper_updates( + self._action_wrapper, TuyaAlarmControlPanelAction.ARM_AWAY + ) async def async_alarm_trigger(self, code: str | None = None) -> None: """Send SOS command.""" - await self._async_send_wrapper_updates(self._action_wrapper, "trigger") + await self._async_send_wrapper_updates( + self._action_wrapper, TuyaAlarmControlPanelAction.TRIGGER + ) diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 642553b128c2e7..bc8ff393746bec 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -4,6 +4,12 @@ from dataclasses import dataclass +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.binary_sensor import ( + DPCodeBitmapBitWrapper, + DPCodeInSetWrapper, +) +from tuya_device_handlers.device_wrapper.common import DPCodeBooleanWrapper from tuya_sharing import CustomerDevice, Manager from homeassistant.components.binary_sensor import ( @@ -19,12 +25,6 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -from .models import ( - DeviceWrapper, - DPCodeBitmapBitWrapper, - DPCodeBooleanWrapper, - DPCodeWrapper, -) @dataclass(frozen=True) @@ -317,6 +317,11 @@ class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, on_value="alarm", ), + TuyaBinarySensorEntityDescription( + key=DPCode.CHARGE_STATE, + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + entity_category=EntityCategory.DIAGNOSTIC, + ), ), DeviceCategory.WK: ( TuyaBinarySensorEntityDescription( @@ -376,29 +381,10 @@ class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): } -class _CustomDPCodeWrapper(DPCodeWrapper): - """Custom DPCode Wrapper to check for values in a set.""" - - _valid_values: set[bool | float | int | str] - - def __init__( - self, dpcode: str, valid_values: set[bool | float | int | str] - ) -> None: - """Init CustomDPCodeBooleanWrapper.""" - super().__init__(dpcode) - self._valid_values = valid_values - - def read_device_status(self, device: CustomerDevice) -> bool | None: - """Read the device value for the dpcode.""" - if (raw_value := device.status.get(self.dpcode)) is None: - return None - return raw_value in self._valid_values - - def _get_dpcode_wrapper( device: CustomerDevice, description: TuyaBinarySensorEntityDescription, -) -> DPCodeWrapper | None: +) -> DeviceWrapper[bool] | None: """Get DPCode wrapper for an entity description.""" dpcode = description.dpcode or description.key if description.bitmap_key is not None: @@ -412,7 +398,7 @@ def _get_dpcode_wrapper( # Legacy / compatibility if dpcode not in device.status: return None - return _CustomDPCodeWrapper( + return DPCodeInSetWrapper( dpcode, description.on_value if isinstance(description.on_value, set) @@ -473,14 +459,16 @@ def is_on(self) -> bool | None: """Return true if sensor is on.""" return self._read_wrapper(self._dpcode_wrapper) - async def _handle_state_update( + async def _process_device_update( self, - updated_status_properties: list[str] | None, + updated_status_properties: list[str], dp_timestamps: dict[str, int] | None, - ) -> None: - """Handle state update, only if this entity's dpcode was actually updated.""" - if self._dpcode_wrapper.skip_update( + ) -> bool: + """Called when Tuya device sends an update with updated properties. + + Returns True if the Home Assistant state should be written, + or False if the state write should be skipped. + """ + return not self._dpcode_wrapper.skip_update( self.device, updated_status_properties, dp_timestamps - ): - return - self.async_write_ha_state() + ) diff --git a/homeassistant/components/tuya/button.py b/homeassistant/components/tuya/button.py index c28d351c2e8bfd..f0ca104d169f63 100644 --- a/homeassistant/components/tuya/button.py +++ b/homeassistant/components/tuya/button.py @@ -2,6 +2,8 @@ from __future__ import annotations +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.common import DPCodeBooleanWrapper from tuya_sharing import CustomerDevice, Manager from homeassistant.components.button import ButtonEntity, ButtonEntityDescription @@ -13,7 +15,6 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -from .models import DeviceWrapper, DPCodeBooleanWrapper BUTTONS: dict[DeviceCategory, tuple[ButtonEntityDescription, ...]] = { DeviceCategory.HXD: ( diff --git a/homeassistant/components/tuya/camera.py b/homeassistant/components/tuya/camera.py index 96eb7c4140289a..bb0ed4982a1b95 100644 --- a/homeassistant/components/tuya/camera.py +++ b/homeassistant/components/tuya/camera.py @@ -2,6 +2,8 @@ from __future__ import annotations +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.common import DPCodeBooleanWrapper from tuya_sharing import CustomerDevice, Manager from homeassistant.components import ffmpeg @@ -13,7 +15,6 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -from .models import DeviceWrapper, DPCodeBooleanWrapper CAMERAS: tuple[DeviceCategory, ...] = ( DeviceCategory.DGHSXJ, diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 939b5989a6f727..6fa15e7615922f 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -2,10 +2,25 @@ from __future__ import annotations -import collections from dataclasses import dataclass -from typing import Any, Self +from typing import Any, cast +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.climate import ( + DefaultHVACModeWrapper, + DefaultPresetModeWrapper, + SwingModeCompositeWrapper, +) +from tuya_device_handlers.device_wrapper.common import ( + DPCodeBooleanWrapper, + DPCodeEnumWrapper, + DPCodeIntegerWrapper, +) +from tuya_device_handlers.device_wrapper.extended import DPCodeRoundedIntegerWrapper +from tuya_device_handlers.helpers.homeassistant import ( + TuyaClimateHVACMode, + TuyaClimateSwingMode, +) from tuya_sharing import CustomerDevice, Manager from homeassistant.components.climate import ( @@ -33,179 +48,26 @@ DPCode, ) from .entity import TuyaEntity -from .models import ( - DeviceWrapper, - DPCodeBooleanWrapper, - DPCodeEnumWrapper, - DPCodeIntegerWrapper, -) -from .type_information import EnumTypeInformation - -TUYA_HVAC_TO_HA = { - "auto": HVACMode.HEAT_COOL, - "cold": HVACMode.COOL, - "freeze": HVACMode.COOL, - "heat": HVACMode.HEAT, - "hot": HVACMode.HEAT, - "manual": HVACMode.HEAT_COOL, - "off": HVACMode.OFF, - "wet": HVACMode.DRY, - "wind": HVACMode.FAN_ONLY, -} - - -class _RoundedIntegerWrapper(DPCodeIntegerWrapper): - """An integer that always rounds its value.""" - - def read_device_status(self, device: CustomerDevice) -> int | None: - """Read and round the device status.""" - if (value := super().read_device_status(device)) is None: - return None - return round(value) - - -@dataclass(kw_only=True) -class _SwingModeWrapper(DeviceWrapper): - """Wrapper for managing climate swing mode operations across multiple DPCodes.""" - - on_off: DPCodeBooleanWrapper | None = None - horizontal: DPCodeBooleanWrapper | None = None - vertical: DPCodeBooleanWrapper | None = None - options: list[str] - - @classmethod - def find_dpcode(cls, device: CustomerDevice) -> Self | None: - """Find and return a _SwingModeWrapper for the given DP codes.""" - on_off = DPCodeBooleanWrapper.find_dpcode( - device, (DPCode.SWING, DPCode.SHAKE), prefer_function=True - ) - horizontal = DPCodeBooleanWrapper.find_dpcode( - device, DPCode.SWITCH_HORIZONTAL, prefer_function=True - ) - vertical = DPCodeBooleanWrapper.find_dpcode( - device, DPCode.SWITCH_VERTICAL, prefer_function=True - ) - if on_off or horizontal or vertical: - options = [SWING_OFF] - if on_off: - options.append(SWING_ON) - if horizontal: - options.append(SWING_HORIZONTAL) - if vertical: - options.append(SWING_VERTICAL) - return cls( - on_off=on_off, - horizontal=horizontal, - vertical=vertical, - options=options, - ) - return None - - def read_device_status(self, device: CustomerDevice) -> str | None: - """Read the device swing mode.""" - if self.on_off and self.on_off.read_device_status(device): - return SWING_ON - - horizontal = ( - self.horizontal.read_device_status(device) if self.horizontal else None - ) - vertical = self.vertical.read_device_status(device) if self.vertical else None - if horizontal and vertical: - return SWING_BOTH - if horizontal: - return SWING_HORIZONTAL - if vertical: - return SWING_VERTICAL - - return SWING_OFF - - def get_update_commands( - self, device: CustomerDevice, value: str - ) -> list[dict[str, Any]]: - """Set new target swing operation.""" - commands = [] - if self.on_off: - commands.extend(self.on_off.get_update_commands(device, value == SWING_ON)) - if self.vertical: - commands.extend( - self.vertical.get_update_commands( - device, value in (SWING_BOTH, SWING_VERTICAL) - ) - ) - if self.horizontal: - commands.extend( - self.horizontal.get_update_commands( - device, value in (SWING_BOTH, SWING_HORIZONTAL) - ) - ) - return commands - - -def _filter_hvac_mode_mappings(tuya_range: list[str]) -> dict[str, HVACMode | None]: - """Filter TUYA_HVAC_TO_HA modes that are not in the range. - - If multiple Tuya modes map to the same HA mode, set the mapping to None to avoid - ambiguity when converting back from HA to Tuya modes. - """ - modes_in_range = { - tuya_mode: TUYA_HVAC_TO_HA.get(tuya_mode) for tuya_mode in tuya_range - } - modes_occurrences = collections.Counter(modes_in_range.values()) - for key, value in modes_in_range.items(): - if value is not None and modes_occurrences[value] > 1: - modes_in_range[key] = None - return modes_in_range - - -class _HvacModeWrapper(DPCodeEnumWrapper): - """Wrapper for managing climate HVACMode.""" - - # Modes that do not map to HVAC modes are ignored (they are handled by PresetWrapper) - - def __init__(self, dpcode: str, type_information: EnumTypeInformation) -> None: - """Init _HvacModeWrapper.""" - super().__init__(dpcode, type_information) - self._mappings = _filter_hvac_mode_mappings(type_information.range) - self.options = [ - ha_mode for ha_mode in self._mappings.values() if ha_mode is not None - ] - - def read_device_status(self, device: CustomerDevice) -> HVACMode | None: - """Read the device status.""" - if (raw := super().read_device_status(device)) not in TUYA_HVAC_TO_HA: - return None - return TUYA_HVAC_TO_HA[raw] - - def _convert_value_to_raw_value( - self, device: CustomerDevice, value: HVACMode - ) -> Any: - """Convert value to raw value.""" - return next( - tuya_mode - for tuya_mode, ha_mode in self._mappings.items() - if ha_mode == value - ) - - -class _PresetWrapper(DPCodeEnumWrapper): - """Wrapper for managing climate preset modes.""" - - # Modes that map to HVAC modes are ignored (they are handled by HVACModeWrapper) - - def __init__(self, dpcode: str, type_information: EnumTypeInformation) -> None: - """Init _PresetWrapper.""" - super().__init__(dpcode, type_information) - mappings = _filter_hvac_mode_mappings(type_information.range) - self.options = [ - tuya_mode for tuya_mode, ha_mode in mappings.items() if ha_mode is None - ] - - def read_device_status(self, device: CustomerDevice) -> str | None: - """Read the device status.""" - if (raw := super().read_device_status(device)) in TUYA_HVAC_TO_HA: - return None - return raw +_TUYA_TO_HA_HVACMODE_MAPPINGS = { + TuyaClimateHVACMode.OFF: HVACMode.OFF, + TuyaClimateHVACMode.HEAT: HVACMode.HEAT, + TuyaClimateHVACMode.COOL: HVACMode.COOL, + TuyaClimateHVACMode.FAN_ONLY: HVACMode.FAN_ONLY, + TuyaClimateHVACMode.DRY: HVACMode.DRY, + TuyaClimateHVACMode.HEAT_COOL: HVACMode.HEAT_COOL, + TuyaClimateHVACMode.AUTO: HVACMode.AUTO, +} +_HA_TO_TUYA_HVACMODE_MAPPINGS = {v: k for k, v in _TUYA_TO_HA_HVACMODE_MAPPINGS.items()} + +_TUYA_TO_HA_SWING_MAPPINGS = { + TuyaClimateSwingMode.BOTH: SWING_BOTH, + TuyaClimateSwingMode.HORIZONTAL: SWING_HORIZONTAL, + TuyaClimateSwingMode.OFF: SWING_OFF, + TuyaClimateSwingMode.ON: SWING_ON, + TuyaClimateSwingMode.VERTICAL: SWING_VERTICAL, +} +_HA_TO_TUYA_SWING_MAPPINGS = {v: k for k, v in _TUYA_TO_HA_SWING_MAPPINGS.items()} @dataclass(frozen=True, kw_only=True) @@ -356,7 +218,7 @@ def async_discover_device(device_ids: list[str]) -> None: device, manager, CLIMATE_DESCRIPTIONS[device.category], - current_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode( + current_humidity_wrapper=DPCodeRoundedIntegerWrapper.find_dpcode( device, DPCode.HUMIDITY_CURRENT ), current_temperature_wrapper=temperature_wrappers[0], @@ -365,18 +227,18 @@ def async_discover_device(device_ids: list[str]) -> None: (DPCode.FAN_SPEED_ENUM, DPCode.LEVEL, DPCode.WINDSPEED), prefer_function=True, ), - hvac_mode_wrapper=_HvacModeWrapper.find_dpcode( + hvac_mode_wrapper=DefaultHVACModeWrapper.find_dpcode( device, DPCode.MODE, prefer_function=True ), - preset_wrapper=_PresetWrapper.find_dpcode( + preset_wrapper=DefaultPresetModeWrapper.find_dpcode( device, DPCode.MODE, prefer_function=True ), set_temperature_wrapper=temperature_wrappers[1], - swing_wrapper=_SwingModeWrapper.find_dpcode(device), + swing_wrapper=SwingModeCompositeWrapper.find_dpcode(device), switch_wrapper=DPCodeBooleanWrapper.find_dpcode( device, DPCode.SWITCH, prefer_function=True ), - target_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode( + target_humidity_wrapper=DPCodeRoundedIntegerWrapper.find_dpcode( device, DPCode.HUMIDITY_SET, prefer_function=True ), temperature_unit=temperature_wrappers[2], @@ -406,10 +268,10 @@ def __init__( current_humidity_wrapper: DeviceWrapper[int] | None, current_temperature_wrapper: DeviceWrapper[float] | None, fan_mode_wrapper: DeviceWrapper[str] | None, - hvac_mode_wrapper: DeviceWrapper[HVACMode] | None, + hvac_mode_wrapper: DeviceWrapper[TuyaClimateHVACMode] | None, preset_wrapper: DeviceWrapper[str] | None, set_temperature_wrapper: DeviceWrapper[float] | None, - swing_wrapper: DeviceWrapper[str] | None, + swing_wrapper: DeviceWrapper[TuyaClimateSwingMode] | None, switch_wrapper: DeviceWrapper[bool] | None, target_humidity_wrapper: DeviceWrapper[int] | None, temperature_unit: UnitOfTemperature, @@ -442,10 +304,12 @@ def __init__( self._attr_hvac_modes = [] if hvac_mode_wrapper: self._attr_hvac_modes = [HVACMode.OFF] - for mode in hvac_mode_wrapper.options: - if mode != HVACMode.OFF: + for tuya_mode in cast(list[TuyaClimateHVACMode], hvac_mode_wrapper.options): + if ( + ha_mode := _TUYA_TO_HA_HVACMODE_MAPPINGS.get(tuya_mode) + ) and ha_mode != HVACMode.OFF: # OFF is always added first - self._attr_hvac_modes.append(HVACMode(mode)) + self._attr_hvac_modes.append(ha_mode) elif switch_wrapper: self._attr_hvac_modes = [ @@ -473,7 +337,13 @@ def __init__( # Determine swing modes if swing_wrapper: self._attr_supported_features |= ClimateEntityFeature.SWING_MODE - self._attr_swing_modes = swing_wrapper.options + self._attr_swing_modes = [ + ha_swing_mode + for tuya_swing_mode in cast( + list[TuyaClimateSwingMode], swing_wrapper.options + ) + if (ha_swing_mode := _TUYA_TO_HA_SWING_MAPPINGS.get(tuya_swing_mode)) + ] if switch_wrapper: self._attr_supported_features |= ( @@ -489,9 +359,13 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: self.device, hvac_mode != HVACMode.OFF ) ) - if self._hvac_mode_wrapper and hvac_mode in self._hvac_mode_wrapper.options: + if ( + self._hvac_mode_wrapper + and (tuya_mode := _HA_TO_TUYA_HVACMODE_MAPPINGS.get(hvac_mode)) + and tuya_mode in self._hvac_mode_wrapper.options + ): commands.extend( - self._hvac_mode_wrapper.get_update_commands(self.device, hvac_mode) + self._hvac_mode_wrapper.get_update_commands(self.device, tuya_mode) ) await self._async_send_commands(commands) @@ -509,7 +383,8 @@ async def async_set_humidity(self, humidity: int) -> None: async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new target swing operation.""" - await self._async_send_wrapper_updates(self._swing_wrapper, swing_mode) + if tuya_mode := _HA_TO_TUYA_SWING_MAPPINGS.get(swing_mode): + await self._async_send_wrapper_updates(self._swing_wrapper, tuya_mode) async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -552,7 +427,8 @@ def hvac_mode(self) -> HVACMode | None: return None # If we do have a mode wrapper, check if the mode maps to an HVAC mode. - return self._read_wrapper(self._hvac_mode_wrapper) + tuya_mode = self._read_wrapper(self._hvac_mode_wrapper) + return _TUYA_TO_HA_HVACMODE_MAPPINGS.get(tuya_mode) if tuya_mode else None @property def preset_mode(self) -> str | None: @@ -567,7 +443,8 @@ def fan_mode(self) -> str | None: @property def swing_mode(self) -> str | None: """Return swing mode.""" - return self._read_wrapper(self._swing_wrapper) + tuya_value = self._read_wrapper(self._swing_wrapper) + return _TUYA_TO_HA_SWING_MAPPINGS.get(tuya_value) if tuya_value else None async def async_turn_on(self) -> None: """Turn the device on, retaining current HVAC (if supported).""" diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index dde6d329e1ad9b..aa57cb08d5feda 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -82,18 +82,6 @@ class WorkMode(StrEnum): WHITE = "white" -class DPType(StrEnum): - """Data point types.""" - - BITMAP = "Bitmap" - BOOLEAN = "Boolean" - ENUM = "Enum" - INTEGER = "Integer" - JSON = "Json" - RAW = "Raw" - STRING = "String" - - class DeviceCategory(StrEnum): """Tuya device categories. diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index 1813b6964ca3f3..e7fff0e2d6e53a 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -5,6 +5,20 @@ from dataclasses import dataclass from typing import Any +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.cover import ( + ControlBackModePercentageMappingWrapper, + CoverClosedEnumWrapper, + CoverInstructionBooleanWrapper, + CoverInstructionEnumWrapper, + CoverInstructionSpecialEnumWrapper, +) +from tuya_device_handlers.device_wrapper.extended import ( + DPCodeInvertedBooleanWrapper, + DPCodeInvertedPercentageWrapper, + DPCodePercentageWrapper, +) +from tuya_device_handlers.helpers.homeassistant import TuyaCoverAction from tuya_sharing import CustomerDevice, Manager from homeassistant.components.cover import ( @@ -22,118 +36,6 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -from .models import ( - DeviceWrapper, - DPCodeBooleanWrapper, - DPCodeEnumWrapper, - DPCodeIntegerWrapper, -) -from .type_information import EnumTypeInformation, IntegerTypeInformation -from .util import RemapHelper - - -class _DPCodePercentageMappingWrapper(DPCodeIntegerWrapper): - """Wrapper for DPCode position values mapping to 0-100 range.""" - - def __init__(self, dpcode: str, type_information: IntegerTypeInformation) -> None: - """Init DPCodeIntegerWrapper.""" - super().__init__(dpcode, type_information) - self._remap_helper = RemapHelper.from_type_information(type_information, 0, 100) - - def _position_reversed(self, device: CustomerDevice) -> bool: - """Check if the position and direction should be reversed.""" - return False - - def read_device_status(self, device: CustomerDevice) -> float | None: - if (value := device.status.get(self.dpcode)) is None: - return None - - return round( - self._remap_helper.remap_value_to( - value, reverse=self._position_reversed(device) - ) - ) - - def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any: - return round( - self._remap_helper.remap_value_from( - value, reverse=self._position_reversed(device) - ) - ) - - -class _InvertedPercentageMappingWrapper(_DPCodePercentageMappingWrapper): - """Wrapper for DPCode position values mapping to 0-100 range.""" - - def _position_reversed(self, device: CustomerDevice) -> bool: - """Check if the position and direction should be reversed.""" - return True - - -class _ControlBackModePercentageMappingWrapper(_DPCodePercentageMappingWrapper): - """Wrapper for DPCode position values with control_back_mode support.""" - - def _position_reversed(self, device: CustomerDevice) -> bool: - """Check if the position and direction should be reversed.""" - return device.status.get(DPCode.CONTROL_BACK_MODE) != "back" - - -class _InstructionBooleanWrapper(DPCodeBooleanWrapper): - """Wrapper for boolean-based open/close instructions.""" - - options = ["open", "close"] - _ACTION_MAPPINGS = {"open": True, "close": False} - - def _convert_value_to_raw_value(self, device: CustomerDevice, value: str) -> bool: - return self._ACTION_MAPPINGS[value] - - -class _InstructionEnumWrapper(DPCodeEnumWrapper): - """Wrapper for enum-based open/close/stop instructions.""" - - _ACTION_MAPPINGS = {"open": "open", "close": "close", "stop": "stop"} - - def __init__(self, dpcode: str, type_information: EnumTypeInformation) -> None: - super().__init__(dpcode, type_information) - self.options = [ - ha_action - for ha_action, tuya_action in self._ACTION_MAPPINGS.items() - if tuya_action in type_information.range - ] - - def _convert_value_to_raw_value(self, device: CustomerDevice, value: str) -> str: - return self._ACTION_MAPPINGS[value] - - -class _SpecialInstructionEnumWrapper(_InstructionEnumWrapper): - """Wrapper for enum-based instructions with special values (FZ/ZZ/STOP).""" - - _ACTION_MAPPINGS = {"open": "FZ", "close": "ZZ", "stop": "STOP"} - - -class _IsClosedInvertedWrapper(DPCodeBooleanWrapper): - """Boolean wrapper for checking if cover is closed (inverted).""" - - def read_device_status(self, device: CustomerDevice) -> bool | None: - if (value := super().read_device_status(device)) is None: - return None - return not value - - -class _IsClosedEnumWrapper(DPCodeEnumWrapper): - """Enum wrapper for checking if state is closed.""" - - _MAPPINGS = { - "close": True, - "fully_close": True, - "open": False, - "fully_open": False, - } - - def read_device_status(self, device: CustomerDevice) -> bool | None: - if (value := super().read_device_status(device)) is None: - return None - return self._MAPPINGS.get(value) @dataclass(frozen=True) @@ -141,14 +43,12 @@ class TuyaCoverEntityDescription(CoverEntityDescription): """Describe a Tuya cover entity.""" current_state: DPCode | tuple[DPCode, ...] | None = None - current_state_wrapper: type[_IsClosedInvertedWrapper | _IsClosedEnumWrapper] = ( - _IsClosedEnumWrapper - ) + current_state_wrapper: type[ + DPCodeInvertedBooleanWrapper | CoverClosedEnumWrapper + ] = CoverClosedEnumWrapper current_position: DPCode | tuple[DPCode, ...] | None = None - instruction_wrapper: type[_InstructionEnumWrapper] = _InstructionEnumWrapper - position_wrapper: type[_DPCodePercentageMappingWrapper] = ( - _InvertedPercentageMappingWrapper - ) + instruction_wrapper: type[CoverInstructionEnumWrapper] = CoverInstructionEnumWrapper + position_wrapper: type[DPCodePercentageWrapper] = DPCodeInvertedPercentageWrapper set_position: DPCode | None = None @@ -159,7 +59,7 @@ class TuyaCoverEntityDescription(CoverEntityDescription): translation_key="indexed_door", translation_placeholders={"index": "1"}, current_state=DPCode.DOORCONTACT_STATE, - current_state_wrapper=_IsClosedInvertedWrapper, + current_state_wrapper=DPCodeInvertedBooleanWrapper, device_class=CoverDeviceClass.GARAGE, ), TuyaCoverEntityDescription( @@ -167,7 +67,7 @@ class TuyaCoverEntityDescription(CoverEntityDescription): translation_key="indexed_door", translation_placeholders={"index": "2"}, current_state=DPCode.DOORCONTACT_STATE_2, - current_state_wrapper=_IsClosedInvertedWrapper, + current_state_wrapper=DPCodeInvertedBooleanWrapper, device_class=CoverDeviceClass.GARAGE, ), TuyaCoverEntityDescription( @@ -175,7 +75,7 @@ class TuyaCoverEntityDescription(CoverEntityDescription): translation_key="indexed_door", translation_placeholders={"index": "3"}, current_state=DPCode.DOORCONTACT_STATE_3, - current_state_wrapper=_IsClosedInvertedWrapper, + current_state_wrapper=DPCodeInvertedBooleanWrapper, device_class=CoverDeviceClass.GARAGE, ), ), @@ -210,7 +110,7 @@ class TuyaCoverEntityDescription(CoverEntityDescription): current_position=DPCode.POSITION, set_position=DPCode.POSITION, device_class=CoverDeviceClass.CURTAIN, - instruction_wrapper=_SpecialInstructionEnumWrapper, + instruction_wrapper=CoverInstructionSpecialEnumWrapper, ), # switch_1 is an undocumented code that behaves identically to control # It is used by the Kogan Smart Blinds Driver @@ -227,7 +127,7 @@ class TuyaCoverEntityDescription(CoverEntityDescription): key=DPCode.CONTROL, translation_key="curtain", current_position=DPCode.PERCENT_CONTROL, - position_wrapper=_ControlBackModePercentageMappingWrapper, + position_wrapper=ControlBackModePercentageMappingWrapper, set_position=DPCode.PERCENT_CONTROL, device_class=CoverDeviceClass.CURTAIN, ), @@ -236,7 +136,7 @@ class TuyaCoverEntityDescription(CoverEntityDescription): translation_key="indexed_curtain", translation_placeholders={"index": "2"}, current_position=DPCode.PERCENT_CONTROL_2, - position_wrapper=_ControlBackModePercentageMappingWrapper, + position_wrapper=ControlBackModePercentageMappingWrapper, set_position=DPCode.PERCENT_CONTROL_2, device_class=CoverDeviceClass.CURTAIN, ), @@ -263,7 +163,7 @@ def _get_instruction_wrapper( return enum_wrapper # Fallback to a boolean wrapper if available - return _InstructionBooleanWrapper.find_dpcode( + return CoverInstructionBooleanWrapper.find_dpcode( device, description.key, prefer_function=True ) @@ -335,7 +235,7 @@ def __init__( *, current_position: DeviceWrapper[int] | None, current_state_wrapper: DeviceWrapper[bool] | None, - instruction_wrapper: DeviceWrapper[str] | None, + instruction_wrapper: DeviceWrapper[TuyaCoverAction] | None, set_position: DeviceWrapper[int] | None, tilt_position: DeviceWrapper[int] | None, ) -> None: @@ -352,11 +252,11 @@ def __init__( self._tilt_position = tilt_position if instruction_wrapper: - if "open" in instruction_wrapper.options: + if TuyaCoverAction.OPEN in instruction_wrapper.options: self._attr_supported_features |= CoverEntityFeature.OPEN - if "close" in instruction_wrapper.options: + if TuyaCoverAction.CLOSE in instruction_wrapper.options: self._attr_supported_features |= CoverEntityFeature.CLOSE - if "stop" in instruction_wrapper.options: + if TuyaCoverAction.STOP in instruction_wrapper.options: self._attr_supported_features |= CoverEntityFeature.STOP if set_position: @@ -396,10 +296,11 @@ async def async_open_cover(self, **kwargs: Any) -> None: if ( self._instruction_wrapper - and (options := self._instruction_wrapper.options) - and "open" in options + and TuyaCoverAction.OPEN in self._instruction_wrapper.options ): - await self._async_send_wrapper_updates(self._instruction_wrapper, "open") + await self._async_send_wrapper_updates( + self._instruction_wrapper, TuyaCoverAction.OPEN + ) async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" @@ -411,10 +312,11 @@ async def async_close_cover(self, **kwargs: Any) -> None: if ( self._instruction_wrapper - and (options := self._instruction_wrapper.options) - and "close" in options + and TuyaCoverAction.CLOSE in self._instruction_wrapper.options ): - await self._async_send_wrapper_updates(self._instruction_wrapper, "close") + await self._async_send_wrapper_updates( + self._instruction_wrapper, TuyaCoverAction.CLOSE + ) async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" @@ -424,8 +326,13 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - if self._instruction_wrapper and "stop" in self._instruction_wrapper.options: - await self._async_send_wrapper_updates(self._instruction_wrapper, "stop") + if ( + self._instruction_wrapper + and TuyaCoverAction.STOP in self._instruction_wrapper.options + ): + await self._async_send_wrapper_updates( + self._instruction_wrapper, TuyaCoverAction.STOP + ) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" diff --git a/homeassistant/components/tuya/diagnostics.py b/homeassistant/components/tuya/diagnostics.py index 75abb9144276cf..ff4b64e67cde3d 100644 --- a/homeassistant/components/tuya/diagnostics.py +++ b/homeassistant/components/tuya/diagnostics.py @@ -4,6 +4,7 @@ from typing import Any +from tuya_device_handlers.device_wrapper import DEVICE_WARNINGS from tuya_sharing import CustomerDevice from homeassistant.components.diagnostics import REDACTED @@ -14,7 +15,6 @@ from . import TuyaConfigEntry from .const import DOMAIN, DPCode -from .type_information import DEVICE_WARNINGS _REDACTED_DPCODES = { DPCode.ALARM_MESSAGE, diff --git a/homeassistant/components/tuya/entity.py b/homeassistant/components/tuya/entity.py index c6cc76c22cf4e1..4581552c226328 100644 --- a/homeassistant/components/tuya/entity.py +++ b/homeassistant/components/tuya/entity.py @@ -4,6 +4,7 @@ from typing import Any +from tuya_device_handlers.device_wrapper import DeviceWrapper from tuya_sharing import CustomerDevice, Manager from homeassistant.helpers.device_registry import DeviceInfo @@ -11,7 +12,6 @@ from homeassistant.helpers.entity import Entity from .const import DOMAIN, LOGGER, TUYA_HA_SIGNAL_UPDATE_ENTITY -from .models import DeviceWrapper class TuyaEntity(Entity): @@ -59,7 +59,33 @@ async def _handle_state_update( updated_status_properties: list[str] | None, dp_timestamps: dict[str, int] | None, ) -> None: - self.async_write_ha_state() + """Called when Tuya device sends an update.""" + if ( + # If updated_status_properties is None, we should not skip, + # as we don't have information on what was updated + # This happens for example on online/offline updates, where + # we still want to update the entity state but we have nothing + # to process + updated_status_properties is None + # If we have data to process, we check if we should skip the + # state_write based on the dpcode wrapper logic + or await self._process_device_update( + updated_status_properties, dp_timestamps + ) + ): + self.async_write_ha_state() + + async def _process_device_update( + self, + updated_status_properties: list[str], + dp_timestamps: dict[str, int] | None, + ) -> bool: + """Called when Tuya device sends an update with updated properties. + + Returns True if the Home Assistant state should be written, + or False if the state write should be skipped. + """ + return True async def _async_send_commands(self, commands: list[dict[str, Any]]) -> None: """Send a list of commands to the device.""" diff --git a/homeassistant/components/tuya/event.py b/homeassistant/components/tuya/event.py index 4ac2c269fa347c..8ebd6befef8b6c 100644 --- a/homeassistant/components/tuya/event.py +++ b/homeassistant/components/tuya/event.py @@ -2,10 +2,16 @@ from __future__ import annotations -from base64 import b64decode from dataclasses import dataclass from typing import Any +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.common import DPCodeTypeInformationWrapper +from tuya_device_handlers.device_wrapper.event import ( + Base64Utf8RawEventWrapper, + Base64Utf8StringEventWrapper, + SimpleEventEnumWrapper, +) from tuya_sharing import CustomerDevice, Manager from homeassistant.components.event import ( @@ -20,67 +26,13 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -from .models import ( - DeviceWrapper, - DPCodeEnumWrapper, - DPCodeRawWrapper, - DPCodeStringWrapper, - DPCodeTypeInformationWrapper, -) - - -class _EventEnumWrapper(DPCodeEnumWrapper): - """Wrapper for event enum DP codes.""" - - def read_device_status(self, device: CustomerDevice) -> tuple[str, None] | None: - """Return the event details.""" - if (raw_value := super().read_device_status(device)) is None: - return None - return (raw_value, None) - - -class _AlarmMessageWrapper(DPCodeStringWrapper): - """Wrapper for a STRING message on DPCode.ALARM_MESSAGE.""" - - def __init__(self, dpcode: str, type_information: Any) -> None: - """Init _AlarmMessageWrapper.""" - super().__init__(dpcode, type_information) - self.options = ["triggered"] - - def read_device_status( - self, device: CustomerDevice - ) -> tuple[str, dict[str, Any]] | None: - """Return the event attributes for the alarm message.""" - if (raw_value := super().read_device_status(device)) is None: - return None - return ("triggered", {"message": b64decode(raw_value).decode("utf-8")}) - - -class _DoorbellPicWrapper(DPCodeRawWrapper): - """Wrapper for a RAW message on DPCode.DOORBELL_PIC. - - It is expected that the RAW data is base64/utf8 encoded URL of the picture. - """ - - def __init__(self, dpcode: str, type_information: Any) -> None: - """Init _DoorbellPicWrapper.""" - super().__init__(dpcode, type_information) - self.options = ["triggered"] - - def read_device_status( - self, device: CustomerDevice - ) -> tuple[str, dict[str, Any]] | None: - """Return the event attributes for the doorbell picture.""" - if (status := super().read_device_status(device)) is None: - return None - return ("triggered", {"message": status.decode("utf-8")}) @dataclass(frozen=True) class TuyaEventEntityDescription(EventEntityDescription): """Describe a Tuya Event entity.""" - wrapper_class: type[DPCodeTypeInformationWrapper] = _EventEnumWrapper + wrapper_class: type[DPCodeTypeInformationWrapper] = SimpleEventEnumWrapper # All descriptions can be found here. Mostly the Enum data types in the @@ -92,13 +44,13 @@ class TuyaEventEntityDescription(EventEntityDescription): key=DPCode.ALARM_MESSAGE, device_class=EventDeviceClass.DOORBELL, translation_key="doorbell_message", - wrapper_class=_AlarmMessageWrapper, + wrapper_class=Base64Utf8StringEventWrapper, ), TuyaEventEntityDescription( key=DPCode.DOORBELL_PIC, device_class=EventDeviceClass.DOORBELL, translation_key="doorbell_picture", - wrapper_class=_DoorbellPicWrapper, + wrapper_class=Base64Utf8RawEventWrapper, ), ), DeviceCategory.WXKG: ( @@ -215,16 +167,21 @@ def __init__( self._dpcode_wrapper = dpcode_wrapper self._attr_event_types = dpcode_wrapper.options - async def _handle_state_update( + async def _process_device_update( self, - updated_status_properties: list[str] | None, + updated_status_properties: list[str], dp_timestamps: dict[str, int] | None, - ) -> None: + ) -> bool: + """Called when Tuya device sends an update with updated properties. + + Returns True if the Home Assistant state should be written, + or False if the state write should be skipped. + """ if self._dpcode_wrapper.skip_update( self.device, updated_status_properties, dp_timestamps ) or not (event_data := self._dpcode_wrapper.read_device_status(self.device)): - return + return False event_type, event_attributes = event_data self._trigger_event(event_type, event_attributes) - self.async_write_ha_state() + return True diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 7cd16296c9a62f..b6d78a28b49825 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -4,6 +4,17 @@ from typing import Any +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.common import ( + DPCodeBooleanWrapper, + DPCodeEnumWrapper, +) +from tuya_device_handlers.device_wrapper.fan import ( + FanDirectionEnumWrapper, + FanSpeedEnumWrapper, + FanSpeedIntegerWrapper, +) +from tuya_device_handlers.helpers.homeassistant import TuyaFanDirection from tuya_sharing import CustomerDevice, Manager from homeassistant.components.fan import ( @@ -15,22 +26,11 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util.percentage import ( - ordered_list_item_to_percentage, - percentage_to_ordered_list_item, -) from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -from .models import ( - DeviceWrapper, - DPCodeBooleanWrapper, - DPCodeEnumWrapper, - DPCodeIntegerWrapper, -) -from .type_information import IntegerTypeInformation -from .util import RemapHelper, get_dpcode +from .util import get_dpcode _DIRECTION_DPCODES = (DPCode.FAN_DIRECTION,) _MODE_DPCODES = (DPCode.FAN_MODE, DPCode.MODE) @@ -52,18 +52,13 @@ DeviceCategory.KS, } - -class _DirectionEnumWrapper(DPCodeEnumWrapper): - """Wrapper for fan direction DP code.""" - - def read_device_status(self, device: CustomerDevice) -> str | None: - """Read the device status and return the direction string.""" - if (value := super().read_device_status(device)) and value in { - DIRECTION_FORWARD, - DIRECTION_REVERSE, - }: - return value - return None +_TUYA_TO_HA_DIRECTION_MAPPINGS = { + TuyaFanDirection.FORWARD: DIRECTION_FORWARD, + TuyaFanDirection.REVERSE: DIRECTION_REVERSE, +} +_HA_TO_TUYA_DIRECTION_MAPPINGS = { + v: k for k, v in _TUYA_TO_HA_DIRECTION_MAPPINGS.items() +} def _has_a_valid_dpcode(device: CustomerDevice) -> bool: @@ -79,50 +74,15 @@ def _has_a_valid_dpcode(device: CustomerDevice) -> bool: return any(get_dpcode(device, code) for code in properties_to_check) -class _FanSpeedEnumWrapper(DPCodeEnumWrapper): - """Wrapper for fan speed DP code (from an enum).""" - - def read_device_status(self, device: CustomerDevice) -> int | None: - """Get the current speed as a percentage.""" - if (value := super().read_device_status(device)) is None: - return None - return ordered_list_item_to_percentage(self.options, value) - - def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any: - """Convert a Home Assistant value back to a raw device value.""" - return percentage_to_ordered_list_item(self.options, value) - - -class _FanSpeedIntegerWrapper(DPCodeIntegerWrapper): - """Wrapper for fan speed DP code (from an integer).""" - - def __init__(self, dpcode: str, type_information: IntegerTypeInformation) -> None: - """Init DPCodeIntegerWrapper.""" - super().__init__(dpcode, type_information) - self._remap_helper = RemapHelper.from_type_information(type_information, 1, 100) - - def read_device_status(self, device: CustomerDevice) -> int | None: - """Get the current speed as a percentage.""" - if (value := super().read_device_status(device)) is None: - return None - return round(self._remap_helper.remap_value_to(value)) - - def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any: - """Convert a Home Assistant value back to a raw device value.""" - return round(self._remap_helper.remap_value_from(value)) - - def _get_speed_wrapper( device: CustomerDevice, -) -> _FanSpeedEnumWrapper | _FanSpeedIntegerWrapper | None: +) -> DeviceWrapper[int] | None: """Get the speed wrapper for the device.""" - if int_wrapper := _FanSpeedIntegerWrapper.find_dpcode( + if int_wrapper := FanSpeedIntegerWrapper.find_dpcode( device, _SPEED_DPCODES, prefer_function=True ): return int_wrapper - return _FanSpeedEnumWrapper.find_dpcode( - device, _SPEED_DPCODES, prefer_function=True - ) + return FanSpeedEnumWrapper.find_dpcode(device, _SPEED_DPCODES, prefer_function=True) async def async_setup_entry( @@ -144,7 +104,7 @@ def async_discover_device(device_ids: list[str]) -> None: TuyaFanEntity( device, manager, - direction_wrapper=_DirectionEnumWrapper.find_dpcode( + direction_wrapper=FanDirectionEnumWrapper.find_dpcode( device, _DIRECTION_DPCODES, prefer_function=True ), mode_wrapper=DPCodeEnumWrapper.find_dpcode( @@ -178,7 +138,7 @@ def __init__( device: CustomerDevice, device_manager: Manager, *, - direction_wrapper: DeviceWrapper[str] | None, + direction_wrapper: DeviceWrapper[TuyaFanDirection] | None, mode_wrapper: DeviceWrapper[str] | None, oscillate_wrapper: DeviceWrapper[bool] | None, speed_wrapper: DeviceWrapper[int] | None, @@ -219,7 +179,8 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: async def async_set_direction(self, direction: str) -> None: """Set the direction of the fan.""" - await self._async_send_wrapper_updates(self._direction_wrapper, direction) + if tuya_value := _HA_TO_TUYA_DIRECTION_MAPPINGS.get(direction): + await self._async_send_wrapper_updates(self._direction_wrapper, tuya_value) async def async_set_percentage(self, percentage: int) -> None: """Set the speed of the fan, as a percentage.""" @@ -264,7 +225,8 @@ def is_on(self) -> bool | None: @property def current_direction(self) -> str | None: """Return the current direction of the fan.""" - return self._read_wrapper(self._direction_wrapper) + tuya_value = self._read_wrapper(self._direction_wrapper) + return _TUYA_TO_HA_DIRECTION_MAPPINGS.get(tuya_value) if tuya_value else None @property def oscillating(self) -> bool | None: diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index 0da70a83563f2e..663bd700724afb 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -5,6 +5,12 @@ from dataclasses import dataclass from typing import Any +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.common import ( + DPCodeBooleanWrapper, + DPCodeEnumWrapper, +) +from tuya_device_handlers.device_wrapper.extended import DPCodeRoundedIntegerWrapper from tuya_sharing import CustomerDevice, Manager from homeassistant.components.humidifier import ( @@ -20,25 +26,9 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -from .models import ( - DeviceWrapper, - DPCodeBooleanWrapper, - DPCodeEnumWrapper, - DPCodeIntegerWrapper, -) from .util import ActionDPCodeNotFoundError, get_dpcode -class _RoundedIntegerWrapper(DPCodeIntegerWrapper): - """An integer that always rounds its value.""" - - def read_device_status(self, device: CustomerDevice) -> int | None: - """Read and round the device status.""" - if (value := super().read_device_status(device)) is None: - return None - return round(value) - - @dataclass(frozen=True) class TuyaHumidifierEntityDescription(HumidifierEntityDescription): """Describe an Tuya (de)humidifier entity.""" @@ -104,7 +94,7 @@ def async_discover_device(device_ids: list[str]) -> None: device, manager, description, - current_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode( + current_humidity_wrapper=DPCodeRoundedIntegerWrapper.find_dpcode( device, description.current_humidity ), mode_wrapper=DPCodeEnumWrapper.find_dpcode( @@ -115,7 +105,7 @@ def async_discover_device(device_ids: list[str]) -> None: description.dpcode or description.key, prefer_function=True, ), - target_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode( + target_humidity_wrapper=DPCodeRoundedIntegerWrapper.find_dpcode( device, description.humidity, prefer_function=True ), ) diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index b28e0c4d4ac44e..513895475a4e5e 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -4,9 +4,23 @@ from dataclasses import dataclass from enum import StrEnum -import json from typing import Any, cast +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.common import ( + DPCodeBooleanWrapper, + DPCodeEnumWrapper, + DPCodeIntegerWrapper, +) +from tuya_device_handlers.device_wrapper.light import ( + DEFAULT_H_TYPE_V2, + DEFAULT_S_TYPE_V2, + DEFAULT_V_TYPE_V2, + BrightnessWrapper, + ColorDataWrapper, + ColorTempWrapper, +) +from tuya_device_handlers.utils import RemapHelper from tuya_sharing import CustomerDevice, Manager from homeassistant.components.light import ( @@ -24,184 +38,11 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util import color as color_util from homeassistant.util.json import json_loads_object from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, WorkMode from .entity import TuyaEntity -from .models import ( - DeviceWrapper, - DPCodeBooleanWrapper, - DPCodeEnumWrapper, - DPCodeIntegerWrapper, - DPCodeJsonWrapper, -) -from .type_information import IntegerTypeInformation -from .util import RemapHelper - - -class _BrightnessWrapper(DPCodeIntegerWrapper): - """Wrapper for brightness DP code. - - Handles brightness value conversion between device scale and Home Assistant's - 0-255 scale. Supports optional dynamic brightness_min and brightness_max - wrappers that allow the device to specify runtime brightness range limits. - """ - - brightness_min: DPCodeIntegerWrapper | None = None - brightness_max: DPCodeIntegerWrapper | None = None - brightness_min_remap: RemapHelper | None = None - brightness_max_remap: RemapHelper | None = None - - def __init__(self, dpcode: str, type_information: IntegerTypeInformation) -> None: - """Init DPCodeIntegerWrapper.""" - super().__init__(dpcode, type_information) - self._remap_helper = RemapHelper.from_type_information(type_information, 0, 255) - - def read_device_status(self, device: CustomerDevice) -> Any | None: - """Return the brightness of this light between 0..255.""" - if (brightness := device.status.get(self.dpcode)) is None: - return None - - # Remap value to our scale - brightness = self._remap_helper.remap_value_to(brightness) - - # If there is a min/max value, the brightness is actually limited. - # Meaning it is actually not on a 0-255 scale. - if ( - self.brightness_max is not None - and self.brightness_min is not None - and self.brightness_max_remap is not None - and self.brightness_min_remap is not None - and (brightness_max := device.status.get(self.brightness_max.dpcode)) - is not None - and (brightness_min := device.status.get(self.brightness_min.dpcode)) - is not None - ): - # Remap values onto our scale - brightness_max = self.brightness_max_remap.remap_value_to(brightness_max) - brightness_min = self.brightness_min_remap.remap_value_to(brightness_min) - - # Remap the brightness value from their min-max to our 0-255 scale - brightness = RemapHelper.remap_value( - brightness, - from_min=brightness_min, - from_max=brightness_max, - to_min=0, - to_max=255, - ) - - return round(brightness) - - def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any: - """Convert a Home Assistant value (0..255) back to a raw device value.""" - # If there is a min/max value, the brightness is actually limited. - # Meaning it is actually not on a 0-255 scale. - if ( - self.brightness_max is not None - and self.brightness_min is not None - and self.brightness_max_remap is not None - and self.brightness_min_remap is not None - and (brightness_max := device.status.get(self.brightness_max.dpcode)) - is not None - and (brightness_min := device.status.get(self.brightness_min.dpcode)) - is not None - ): - # Remap values onto our scale - brightness_max = self.brightness_max_remap.remap_value_to(brightness_max) - brightness_min = self.brightness_min_remap.remap_value_to(brightness_min) - - # Remap the brightness value from our 0-255 scale to their min-max - value = RemapHelper.remap_value( - value, - from_min=0, - from_max=255, - to_min=brightness_min, - to_max=brightness_max, - ) - return round(self._remap_helper.remap_value_from(value)) - - -class _ColorTempWrapper(DPCodeIntegerWrapper): - """Wrapper for color temperature DP code.""" - - def __init__(self, dpcode: str, type_information: IntegerTypeInformation) -> None: - """Init DPCodeIntegerWrapper.""" - super().__init__(dpcode, type_information) - self._remap_helper = RemapHelper.from_type_information( - type_information, MIN_MIREDS, MAX_MIREDS - ) - - def read_device_status(self, device: CustomerDevice) -> Any | None: - """Return the color temperature value in Kelvin.""" - if (temperature := device.status.get(self.dpcode)) is None: - return None - - return color_util.color_temperature_mired_to_kelvin( - self._remap_helper.remap_value_to(temperature, reverse=True) - ) - - def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any: - """Convert a Home Assistant value (Kelvin) back to a raw device value.""" - return round( - self._remap_helper.remap_value_from( - color_util.color_temperature_kelvin_to_mired(value), reverse=True - ) - ) - - -DEFAULT_H_TYPE = RemapHelper(source_min=1, source_max=360, target_min=0, target_max=360) -DEFAULT_S_TYPE = RemapHelper(source_min=1, source_max=255, target_min=0, target_max=100) -DEFAULT_V_TYPE = RemapHelper(source_min=1, source_max=255, target_min=0, target_max=255) - - -DEFAULT_H_TYPE_V2 = RemapHelper( - source_min=1, source_max=360, target_min=0, target_max=360 -) -DEFAULT_S_TYPE_V2 = RemapHelper( - source_min=1, source_max=1000, target_min=0, target_max=100 -) -DEFAULT_V_TYPE_V2 = RemapHelper( - source_min=1, source_max=1000, target_min=0, target_max=255 -) - - -class _ColorDataWrapper(DPCodeJsonWrapper): - """Wrapper for color data DP code.""" - - h_type = DEFAULT_H_TYPE - s_type = DEFAULT_S_TYPE - v_type = DEFAULT_V_TYPE - - def read_device_status( - self, device: CustomerDevice - ) -> tuple[float, float, float] | None: - """Return a tuple (H, S, V) from this color data.""" - if (status := super().read_device_status(device)) is None: - return None - return ( - self.h_type.remap_value_to(status["h"]), - self.s_type.remap_value_to(status["s"]), - self.v_type.remap_value_to(status["v"]), - ) - - def _convert_value_to_raw_value( - self, device: CustomerDevice, value: tuple[float, float, float] - ) -> Any: - """Convert a Home Assistant tuple (H, S, V) back to a raw device value.""" - hue, saturation, brightness = value - return json.dumps( - { - "h": round(self.h_type.remap_value_from(hue)), - "s": round(self.s_type.remap_value_from(saturation)), - "v": round(self.v_type.remap_value_from(brightness)), - } - ) - - -MAX_MIREDS = 500 # 2000 K -MIN_MIREDS = 153 # 6500 K class FallbackColorDataMode(StrEnum): @@ -551,9 +392,9 @@ class TuyaLightEntityDescription(LightEntityDescription): def _get_brightness_wrapper( device: CustomerDevice, description: TuyaLightEntityDescription -) -> _BrightnessWrapper | None: +) -> BrightnessWrapper | None: if ( - brightness_wrapper := _BrightnessWrapper.find_dpcode( + brightness_wrapper := BrightnessWrapper.find_dpcode( device, description.brightness, prefer_function=True ) ) is None: @@ -578,10 +419,10 @@ def _get_brightness_wrapper( def _get_color_data_wrapper( device: CustomerDevice, description: TuyaLightEntityDescription, - brightness_wrapper: _BrightnessWrapper | None, -) -> _ColorDataWrapper | None: + brightness_wrapper: BrightnessWrapper | None, +) -> ColorDataWrapper | None: if ( - color_data_wrapper := _ColorDataWrapper.find_dpcode( + color_data_wrapper := ColorDataWrapper.find_dpcode( device, description.color_data, prefer_function=True ) ) is None: @@ -643,7 +484,7 @@ def async_discover_device(device_ids: list[str]): color_mode_wrapper=DPCodeEnumWrapper.find_dpcode( device, description.color_mode, prefer_function=True ), - color_temp_wrapper=_ColorTempWrapper.find_dpcode( + color_temp_wrapper=ColorTempWrapper.find_dpcode( device, description.color_temp, prefer_function=True ), switch_wrapper=switch_wrapper, diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index 7d630ef257c726..a311f5e8e1101b 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -43,5 +43,8 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["tuya_sharing"], - "requirements": ["tuya-device-sharing-sdk==0.2.8"] + "requirements": [ + "tuya-device-handlers==0.0.13", + "tuya-device-sharing-sdk==0.2.8" + ] } diff --git a/homeassistant/components/tuya/models.py b/homeassistant/components/tuya/models.py deleted file mode 100644 index f5937b32a294e2..00000000000000 --- a/homeassistant/components/tuya/models.py +++ /dev/null @@ -1,336 +0,0 @@ -"""Tuya Home Assistant Base Device Model.""" - -from __future__ import annotations - -import logging -from typing import Any, Self - -from tuya_sharing import CustomerDevice - -from homeassistant.components.sensor import SensorStateClass - -from .type_information import ( - BitmapTypeInformation, - BooleanTypeInformation, - EnumTypeInformation, - IntegerTypeInformation, - JsonTypeInformation, - RawTypeInformation, - StringTypeInformation, - TypeInformation, -) - -_LOGGER = logging.getLogger(__name__) - - -class DeviceWrapper[T]: - """Base device wrapper.""" - - native_unit: str | None = None - suggested_unit: str | None = None - state_class: SensorStateClass | None = None - - max_value: float - min_value: float - value_step: float - - options: list[str] - - def initialize(self, device: CustomerDevice) -> None: - """Initialize the wrapper with device data. - - Called when the entity is added to Home Assistant. - Override in subclasses to perform initialization logic. - """ - - def skip_update( - self, - device: CustomerDevice, - updated_status_properties: list[str] | None, - dp_timestamps: dict[str, int] | None, - ) -> bool: - """Determine if the wrapper should skip an update. - - The default is to always skip if updated properties is given, - unless overridden in subclasses. - """ - # If updated_status_properties is None, we should not skip, - # as we don't have information on what was updated - # This happens for example on online/offline updates, where - # we still want to update the entity state - return updated_status_properties is not None - - def read_device_status(self, device: CustomerDevice) -> T | None: - """Read device status and convert to a Home Assistant value.""" - raise NotImplementedError - - def get_update_commands( - self, device: CustomerDevice, value: T - ) -> list[dict[str, Any]]: - """Generate update commands for a Home Assistant action.""" - raise NotImplementedError - - -class DPCodeWrapper(DeviceWrapper): - """Base device wrapper for a single DPCode. - - Used as a common interface for referring to a DPCode, and - access read conversion routines. - """ - - def __init__(self, dpcode: str) -> None: - """Init DPCodeWrapper.""" - self.dpcode = dpcode - - def skip_update( - self, - device: CustomerDevice, - updated_status_properties: list[str] | None, - dp_timestamps: dict[str, int] | None, - ) -> bool: - """Determine if the wrapper should skip an update. - - By default, skip if updated_status_properties is given and - does not include this dpcode. - """ - # If updated_status_properties is None, we should not skip, - # as we don't have information on what was updated - # This happens for example on online/offline updates, where - # we still want to update the entity state - return ( - updated_status_properties is not None - and self.dpcode not in updated_status_properties - ) - - def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any: - """Convert a Home Assistant value back to a raw device value. - - This is called by `get_update_commands` to prepare the value for sending - back to the device, and should be implemented in concrete classes if needed. - """ - raise NotImplementedError - - def get_update_commands( - self, device: CustomerDevice, value: Any - ) -> list[dict[str, Any]]: - """Get the update commands for the dpcode. - - The Home Assistant value is converted back to a raw device value. - """ - return [ - { - "code": self.dpcode, - "value": self._convert_value_to_raw_value(device, value), - } - ] - - -class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper): - """Base DPCode wrapper with Type Information.""" - - _DPTYPE: type[T] - type_information: T - - def __init__(self, dpcode: str, type_information: T) -> None: - """Init DPCodeWrapper.""" - super().__init__(dpcode) - self.type_information = type_information - - def read_device_status(self, device: CustomerDevice) -> Any | None: - """Read the device value for the dpcode.""" - return self.type_information.process_raw_value( - device.status.get(self.dpcode), device - ) - - @classmethod - def find_dpcode( - cls, - device: CustomerDevice, - dpcodes: str | tuple[str, ...] | None, - *, - prefer_function: bool = False, - ) -> Self | None: - """Find and return a DPCodeTypeInformationWrapper for the given DP codes.""" - if type_information := cls._DPTYPE.find_dpcode( - device, dpcodes, prefer_function=prefer_function - ): - return cls( - dpcode=type_information.dpcode, type_information=type_information - ) - return None - - -class DPCodeBooleanWrapper(DPCodeTypeInformationWrapper[BooleanTypeInformation]): - """Simple wrapper for boolean values. - - Supports True/False only. - """ - - _DPTYPE = BooleanTypeInformation - - def _convert_value_to_raw_value( - self, device: CustomerDevice, value: Any - ) -> Any | None: - """Convert a Home Assistant value back to a raw device value.""" - if value in (True, False): - return value - # Currently only called with boolean values - # Safety net in case of future changes - raise ValueError(f"Invalid boolean value `{value}`") - - -class DPCodeJsonWrapper(DPCodeTypeInformationWrapper[JsonTypeInformation]): - """Wrapper to extract information from a JSON value.""" - - _DPTYPE = JsonTypeInformation - - -class DPCodeEnumWrapper(DPCodeTypeInformationWrapper[EnumTypeInformation]): - """Simple wrapper for EnumTypeInformation values.""" - - _DPTYPE = EnumTypeInformation - - def __init__(self, dpcode: str, type_information: EnumTypeInformation) -> None: - """Init DPCodeEnumWrapper.""" - super().__init__(dpcode, type_information) - self.options = type_information.range - - def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any: - """Convert a Home Assistant value back to a raw device value.""" - if value in self.type_information.range: - return value - # Guarded by select option validation - # Safety net in case of future changes - raise ValueError( - f"Enum value `{value}` out of range: {self.type_information.range}" - ) - - -class DPCodeIntegerWrapper(DPCodeTypeInformationWrapper[IntegerTypeInformation]): - """Simple wrapper for IntegerTypeInformation values.""" - - _DPTYPE = IntegerTypeInformation - - def __init__(self, dpcode: str, type_information: IntegerTypeInformation) -> None: - """Init DPCodeIntegerWrapper.""" - super().__init__(dpcode, type_information) - self.native_unit = type_information.unit - self.min_value = self.type_information.scale_value(type_information.min) - self.max_value = self.type_information.scale_value(type_information.max) - self.value_step = self.type_information.scale_value(type_information.step) - - def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any: - """Convert a Home Assistant value back to a raw device value.""" - new_value = round(value * (10**self.type_information.scale)) - if self.type_information.min <= new_value <= self.type_information.max: - return new_value - # Guarded by number validation - # Safety net in case of future changes - raise ValueError( - f"Value `{new_value}` (converted from `{value}`) out of range:" - f" ({self.type_information.min}-{self.type_information.max})" - ) - - -class DPCodeDeltaIntegerWrapper(DPCodeIntegerWrapper): - """Wrapper for integer values with delta report accumulation. - - This wrapper handles sensors that report incremental (delta) values - instead of cumulative totals. It accumulates the delta values locally - to provide a running total. - """ - - _accumulated_value: float = 0 - _last_dp_timestamp: int | None = None - - def __init__(self, dpcode: str, type_information: IntegerTypeInformation) -> None: - """Init DPCodeDeltaIntegerWrapper.""" - super().__init__(dpcode, type_information) - # Delta reports use TOTAL_INCREASING state class - self.state_class = SensorStateClass.TOTAL_INCREASING - - def skip_update( - self, - device: CustomerDevice, - updated_status_properties: list[str] | None, - dp_timestamps: dict[str, int] | None, - ) -> bool: - """Override skip_update to process delta updates. - - Processes delta accumulation before determining if update should be skipped. - """ - # If updated_status_properties is None, we should not skip, - # as we don't have information on what was updated - # This happens for example on online/offline updates, where - # we still want to update the entity state but we have nothing - # to accumulate, so we return False to not skip the update - if updated_status_properties is None: - return False - if ( - super().skip_update(device, updated_status_properties, dp_timestamps) - or dp_timestamps is None - or (current_timestamp := dp_timestamps.get(self.dpcode)) is None - or current_timestamp == self._last_dp_timestamp - or (raw_value := super().read_device_status(device)) is None - ): - return True - - delta = float(raw_value) - self._accumulated_value += delta - _LOGGER.debug( - "Delta update for %s: +%s, total: %s", - self.dpcode, - delta, - self._accumulated_value, - ) - - self._last_dp_timestamp = current_timestamp - return False - - def read_device_status(self, device: CustomerDevice) -> float | None: - """Read device status, returning accumulated value for delta reports.""" - return self._accumulated_value - - -class DPCodeRawWrapper(DPCodeTypeInformationWrapper[RawTypeInformation]): - """Wrapper to extract information from a RAW/binary value.""" - - _DPTYPE = RawTypeInformation - - -class DPCodeStringWrapper(DPCodeTypeInformationWrapper[StringTypeInformation]): - """Wrapper to extract information from a STRING value.""" - - _DPTYPE = StringTypeInformation - - -class DPCodeBitmapBitWrapper(DPCodeWrapper): - """Simple wrapper for a specific bit in bitmap values.""" - - def __init__(self, dpcode: str, mask: int) -> None: - """Init DPCodeBitmapWrapper.""" - super().__init__(dpcode) - self._mask = mask - - def read_device_status(self, device: CustomerDevice) -> bool | None: - """Read the device value for the dpcode.""" - if (raw_value := device.status.get(self.dpcode)) is None: - return None - return (raw_value & (1 << self._mask)) != 0 - - @classmethod - def find_dpcode( - cls, - device: CustomerDevice, - dpcodes: str | tuple[str, ...], - *, - bitmap_key: str, - ) -> Self | None: - """Find and return a DPCodeBitmapBitWrapper for the given DP codes.""" - if ( - type_information := BitmapTypeInformation.find_dpcode(device, dpcodes) - ) and bitmap_key in type_information.label: - return cls( - type_information.dpcode, type_information.label.index(bitmap_key) - ) - return None diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index a8534f4c489b4f..ea24e04a1040e8 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -2,6 +2,8 @@ from __future__ import annotations +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.common import DPCodeIntegerWrapper from tuya_sharing import CustomerDevice, Manager from homeassistant.components.number import ( @@ -25,7 +27,6 @@ DPCode, ) from .entity import TuyaEntity -from .models import DeviceWrapper, DPCodeIntegerWrapper NUMBERS: dict[DeviceCategory, tuple[NumberEntityDescription, ...]] = { DeviceCategory.BH: ( @@ -551,17 +552,19 @@ def native_value(self) -> float | None: """Return the entity value to represent the entity state.""" return self._read_wrapper(self._dpcode_wrapper) - async def _handle_state_update( + async def _process_device_update( self, - updated_status_properties: list[str] | None, + updated_status_properties: list[str], dp_timestamps: dict[str, int] | None, - ) -> None: - """Handle state update, only if this entity's dpcode was actually updated.""" - if self._dpcode_wrapper.skip_update( + ) -> bool: + """Called when Tuya device sends an update with updated properties. + + Returns True if the Home Assistant state should be written, + or False if the state write should be skipped. + """ + return not self._dpcode_wrapper.skip_update( self.device, updated_status_properties, dp_timestamps - ): - return - self.async_write_ha_state() + ) async def async_set_native_value(self, value: float) -> None: """Set new value.""" diff --git a/homeassistant/components/tuya/raw_data_models.py b/homeassistant/components/tuya/raw_data_models.py deleted file mode 100644 index c0ba9947fef074..00000000000000 --- a/homeassistant/components/tuya/raw_data_models.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Parsers for RAW (base64-encoded bytes) values.""" - -from dataclasses import dataclass -import struct -from typing import Self - - -@dataclass(kw_only=True) -class ElectricityData: - """Electricity RAW value.""" - - current: float - power: float - voltage: float - - @classmethod - def from_bytes(cls, raw: bytes) -> Self | None: - """Parse bytes and return an ElectricityValue object.""" - # Format: - # - legacy: 8 bytes - # - v01: [ver=0x01][len=0x0F][data(15 bytes)] - # - v02: [ver=0x02][len=0x0F][data(15 bytes)][sign_bitmap(1 byte)] - # Data layout (big-endian): - # - voltage: 2B, unit 0.1 V - # - current: 3B, unit 0.001 A (i.e., mA) - # - active power: 3B, unit 0.001 kW (i.e., W) - # - reactive power: 3B, unit 0.001 kVar - # - apparent power: 3B, unit 0.001 kVA - # - power factor: 1B, unit 0.01 - # Sign bitmap (v02 only, 1 bit means negative): - # - bit0 current - # - bit1 active power - # - bit2 reactive - # - bit3 power factor - - is_v1 = len(raw) == 17 and raw[0:2] == b"\x01\x0f" - is_v2 = len(raw) == 18 and raw[0:2] == b"\x02\x0f" - if is_v1 or is_v2: - data = raw[2:17] - - voltage = struct.unpack(">H", data[0:2])[0] / 10.0 - current = struct.unpack(">L", b"\x00" + data[2:5])[0] - power = struct.unpack(">L", b"\x00" + data[5:8])[0] - - if is_v2: - sign_bitmap = raw[17] - if sign_bitmap & 0x01: - current = -current - if sign_bitmap & 0x02: - power = -power - - return cls(current=current, power=power, voltage=voltage) - - if len(raw) >= 8: - voltage = struct.unpack(">H", raw[0:2])[0] / 10.0 - current = struct.unpack(">L", b"\x00" + raw[2:5])[0] - power = struct.unpack(">L", b"\x00" + raw[5:8])[0] - return cls(current=current, power=power, voltage=voltage) - - return None diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 8e884d47cf7ea4..67eaf94e10cfff 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -2,6 +2,8 @@ from __future__ import annotations +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.common import DPCodeEnumWrapper from tuya_sharing import CustomerDevice, Manager from homeassistant.components.select import SelectEntity, SelectEntityDescription @@ -13,7 +15,6 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -from .models import DeviceWrapper, DPCodeEnumWrapper # All descriptions can be found here. Mostly the Enum data types in the # default instructions set of each category end up being a select. @@ -407,17 +408,19 @@ def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" return self._read_wrapper(self._dpcode_wrapper) - async def _handle_state_update( + async def _process_device_update( self, - updated_status_properties: list[str] | None, + updated_status_properties: list[str], dp_timestamps: dict[str, int] | None, - ) -> None: - """Handle state update, only if this entity's dpcode was actually updated.""" - if self._dpcode_wrapper.skip_update( + ) -> bool: + """Called when Tuya device sends an update with updated properties. + + Returns True if the Home Assistant state should be written, + or False if the state write should be skipped. + """ + return not self._dpcode_wrapper.skip_update( self.device, updated_status_properties, dp_timestamps - ): - return - self.async_write_ha_state() + ) async def async_select_option(self, option: str) -> None: """Change the selected option.""" diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 8f91f7a4f88464..c391b4de824762 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -4,6 +4,24 @@ from dataclasses import dataclass +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.common import ( + DPCodeEnumWrapper, + DPCodeIntegerWrapper, + DPCodeTypeInformationWrapper, + DPCodeWrapper, +) +from tuya_device_handlers.device_wrapper.sensor import ( + DeltaIntegerWrapper, + ElectricityCurrentJsonWrapper, + ElectricityCurrentRawWrapper, + ElectricityPowerJsonWrapper, + ElectricityPowerRawWrapper, + ElectricityVoltageJsonWrapper, + ElectricityVoltageRawWrapper, + WindDirectionEnumWrapper, +) +from tuya_device_handlers.type_information import IntegerTypeInformation from tuya_sharing import CustomerDevice, Manager from homeassistant.components.sensor import ( @@ -38,138 +56,10 @@ DPCode, ) from .entity import TuyaEntity -from .models import ( - DeviceWrapper, - DPCodeDeltaIntegerWrapper, - DPCodeEnumWrapper, - DPCodeIntegerWrapper, - DPCodeJsonWrapper, - DPCodeRawWrapper, - DPCodeTypeInformationWrapper, - DPCodeWrapper, -) -from .raw_data_models import ElectricityData -from .type_information import EnumTypeInformation, IntegerTypeInformation - - -class _WindDirectionWrapper(DPCodeTypeInformationWrapper[EnumTypeInformation]): - """Custom DPCode Wrapper for converting enum to wind direction.""" - - _DPTYPE = EnumTypeInformation - - _WIND_DIRECTIONS = { - "north": 0.0, - "north_north_east": 22.5, - "north_east": 45.0, - "east_north_east": 67.5, - "east": 90.0, - "east_south_east": 112.5, - "south_east": 135.0, - "south_south_east": 157.5, - "south": 180.0, - "south_south_west": 202.5, - "south_west": 225.0, - "west_south_west": 247.5, - "west": 270.0, - "west_north_west": 292.5, - "north_west": 315.0, - "north_north_west": 337.5, - } - - def read_device_status(self, device: CustomerDevice) -> float | None: - """Read the device value for the dpcode.""" - if (raw_value := device.status.get(self.dpcode)) in self.type_information.range: - return self._WIND_DIRECTIONS.get(raw_value) - return None - - -class _JsonElectricityCurrentWrapper(DPCodeJsonWrapper): - """Custom DPCode Wrapper for extracting electricity current from JSON.""" - - native_unit = UnitOfElectricCurrent.AMPERE - - def read_device_status(self, device: CustomerDevice) -> float | None: - """Read the device value for the dpcode.""" - if (status := super().read_device_status(device)) is None: - return None - return status.get("electricCurrent") - - -class _JsonElectricityPowerWrapper(DPCodeJsonWrapper): - """Custom DPCode Wrapper for extracting electricity power from JSON.""" - - native_unit = UnitOfPower.KILO_WATT - - def read_device_status(self, device: CustomerDevice) -> float | None: - """Read the device value for the dpcode.""" - if (status := super().read_device_status(device)) is None: - return None - return status.get("power") - -class _JsonElectricityVoltageWrapper(DPCodeJsonWrapper): - """Custom DPCode Wrapper for extracting electricity voltage from JSON.""" - - native_unit = UnitOfElectricPotential.VOLT - - def read_device_status(self, device: CustomerDevice) -> float | None: - """Read the device value for the dpcode.""" - if (status := super().read_device_status(device)) is None: - return None - return status.get("voltage") - - -class _RawElectricityDataWrapper(DPCodeRawWrapper): - """Custom DPCode Wrapper for extracting ElectricityData from base64.""" - - def _convert(self, value: ElectricityData) -> float: - """Extract specific value from T.""" - raise NotImplementedError - - def read_device_status(self, device: CustomerDevice) -> float | None: - """Read the device value for the dpcode.""" - if (raw_value := super().read_device_status(device)) is None or ( - value := ElectricityData.from_bytes(raw_value) - ) is None: - return None - return self._convert(value) - - -class _RawElectricityCurrentWrapper(_RawElectricityDataWrapper): - """Custom DPCode Wrapper for extracting electricity current from base64.""" - - native_unit = UnitOfElectricCurrent.MILLIAMPERE - suggested_unit = UnitOfElectricCurrent.AMPERE - - def _convert(self, value: ElectricityData) -> float: - """Extract specific value from ElectricityData.""" - return value.current - - -class _RawElectricityPowerWrapper(_RawElectricityDataWrapper): - """Custom DPCode Wrapper for extracting electricity power from base64.""" - - native_unit = UnitOfPower.WATT - suggested_unit = UnitOfPower.KILO_WATT - - def _convert(self, value: ElectricityData) -> float: - """Extract specific value from ElectricityData.""" - return value.power - - -class _RawElectricityVoltageWrapper(_RawElectricityDataWrapper): - """Custom DPCode Wrapper for extracting electricity voltage from base64.""" - - native_unit = UnitOfElectricPotential.VOLT - - def _convert(self, value: ElectricityData) -> float: - """Extract specific value from ElectricityData.""" - return value.voltage - - -CURRENT_WRAPPER = (_RawElectricityCurrentWrapper, _JsonElectricityCurrentWrapper) -POWER_WRAPPER = (_RawElectricityPowerWrapper, _JsonElectricityPowerWrapper) -VOLTAGE_WRAPPER = (_RawElectricityVoltageWrapper, _JsonElectricityVoltageWrapper) +CURRENT_WRAPPER = (ElectricityCurrentRawWrapper, ElectricityCurrentJsonWrapper) +POWER_WRAPPER = (ElectricityPowerRawWrapper, ElectricityPowerJsonWrapper) +VOLTAGE_WRAPPER = (ElectricityVoltageRawWrapper, ElectricityVoltageJsonWrapper) @dataclass(frozen=True) @@ -1070,7 +960,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): translation_key="wind_direction", device_class=SensorDeviceClass.WIND_DIRECTION, state_class=SensorStateClass.MEASUREMENT, - wrapper_class=(_WindDirectionWrapper,), + wrapper_class=(WindDirectionEnumWrapper,), ), TuyaSensorEntityDescription( key=DPCode.DEW_POINT_TEMP, @@ -1343,6 +1233,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): ), *BATTERY_SENSORS, ), + DeviceCategory.WG2: (*BATTERY_SENSORS,), DeviceCategory.WK: (*BATTERY_SENSORS,), DeviceCategory.WKCZ: ( TuyaSensorEntityDescription( @@ -1744,7 +1635,7 @@ def _get_dpcode_wrapper( # Check for integer type first, using delta wrapper only for sum report_type if type_information := IntegerTypeInformation.find_dpcode(device, dpcode): if type_information.report_type == "sum": - return DPCodeDeltaIntegerWrapper(type_information.dpcode, type_information) + return DeltaIntegerWrapper(type_information.dpcode, type_information) return DPCodeIntegerWrapper(type_information.dpcode, type_information) return DPCodeEnumWrapper.find_dpcode(device, dpcode) @@ -1802,8 +1693,13 @@ def __init__( self._attr_native_unit_of_measurement = dpcode_wrapper.native_unit if description.suggested_unit_of_measurement is None: self._attr_suggested_unit_of_measurement = dpcode_wrapper.suggested_unit - if description.state_class is None: - self._attr_state_class = dpcode_wrapper.state_class + if ( + description.state_class is None + # For integer type DPs with "sum" report type, we can assume it's a total + # increasing sensor + and isinstance(dpcode_wrapper, DeltaIntegerWrapper) + ): + self._attr_state_class = SensorStateClass.TOTAL_INCREASING self._validate_device_class_unit() @@ -1856,14 +1752,16 @@ def native_value(self) -> StateType: """Return the value reported by the sensor.""" return self._read_wrapper(self._dpcode_wrapper) - async def _handle_state_update( + async def _process_device_update( self, - updated_status_properties: list[str] | None, + updated_status_properties: list[str], dp_timestamps: dict[str, int] | None, - ) -> None: - """Handle state update, only if this entity's dpcode was actually updated.""" - if self._dpcode_wrapper.skip_update( + ) -> bool: + """Called when Tuya device sends an update with updated properties. + + Returns True if the Home Assistant state should be written, + or False if the state write should be skipped. + """ + return not self._dpcode_wrapper.skip_update( self.device, updated_status_properties, dp_timestamps - ): - return - self.async_write_ha_state() + ) diff --git a/homeassistant/components/tuya/siren.py b/homeassistant/components/tuya/siren.py index 4bd803b19a04b5..5836f27b2edf9a 100644 --- a/homeassistant/components/tuya/siren.py +++ b/homeassistant/components/tuya/siren.py @@ -4,6 +4,8 @@ from typing import Any +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.common import DPCodeBooleanWrapper from tuya_sharing import CustomerDevice, Manager from homeassistant.components.siren import ( @@ -19,7 +21,6 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -from .models import DeviceWrapper, DPCodeBooleanWrapper SIRENS: dict[DeviceCategory, tuple[SirenEntityDescription, ...]] = { DeviceCategory.CO2BJ: ( @@ -107,17 +108,19 @@ def is_on(self) -> bool | None: """Return true if siren is on.""" return self._read_wrapper(self._dpcode_wrapper) - async def _handle_state_update( + async def _process_device_update( self, - updated_status_properties: list[str] | None, + updated_status_properties: list[str], dp_timestamps: dict[str, int] | None, - ) -> None: - """Handle state update, only if this entity's dpcode was actually updated.""" - if self._dpcode_wrapper.skip_update( + ) -> bool: + """Called when Tuya device sends an update with updated properties. + + Returns True if the Home Assistant state should be written, + or False if the state write should be skipped. + """ + return not self._dpcode_wrapper.skip_update( self.device, updated_status_properties, dp_timestamps - ): - return - self.async_write_ha_state() + ) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the siren on.""" diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index f27065440e37e7..f00c78e7510fee 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -490,9 +490,9 @@ } }, "relay_status": { - "name": "Power on behavior", + "name": "Power-on behavior", "state": { - "last": "Remember last state", + "last": "Previous state", "memory": "[%key:component::tuya::entity::select::relay_status::state::last%]", "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]", @@ -1097,11 +1097,5 @@ "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})." } - }, - "issues": { - "deprecated_entity_new_valve": { - "description": "The Tuya entity `{entity}` is deprecated, replaced by a new valve entity.\nPlease update your dashboards, automations and scripts, disable `{entity}` and reload the integration/restart Home Assistant to fix this issue.", - "title": "{name} is deprecated" - } } } diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index dce5fec0ef07eb..2d23f6404b7fab 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -2,41 +2,25 @@ from __future__ import annotations -from dataclasses import dataclass from typing import Any +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.common import DPCodeBooleanWrapper from tuya_sharing import CustomerDevice, Manager from homeassistant.components.switch import ( - DOMAIN as SWITCH_DOMAIN, SwitchDeviceClass, SwitchEntity, SwitchEntityDescription, ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) from . import TuyaConfigEntry -from .const import DOMAIN, TUYA_DISCOVERY_NEW, DeviceCategory, DPCode +from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -from .models import DeviceWrapper, DPCodeBooleanWrapper - - -@dataclass(frozen=True, kw_only=True) -class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): - """Describes Tuya deprecated switch entity.""" - - deprecated: str - breaks_in_ha_version: str - # All descriptions can be found here. Mostly the Boolean data types in the # default instruction set of each category end up being a Switch. @@ -663,14 +647,6 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): entity_category=EntityCategory.CONFIG, ), ), - DeviceCategory.SFKZQ: ( - TuyaDeprecatedSwitchEntityDescription( - key=DPCode.SWITCH, - translation_key="switch", - deprecated="deprecated_entity_new_valve", - breaks_in_ha_version="2026.4.0", - ), - ), DeviceCategory.SGBJ: ( SwitchEntityDescription( key=DPCode.MUFFLING, @@ -936,7 +912,6 @@ async def async_setup_entry( ) -> None: """Set up tuya sensors dynamically through tuya discovery.""" manager = entry.runtime_data.manager - entity_registry = er.async_get(hass) @callback def async_discover_device(device_ids: list[str]) -> None: @@ -953,12 +928,6 @@ def async_discover_device(device_ids: list[str]) -> None: device, description.key, prefer_function=True ) ) - and _check_deprecation( - hass, - device, - description, - entity_registry, - ) ) async_add_entities(entities) @@ -970,55 +939,6 @@ def async_discover_device(device_ids: list[str]) -> None: ) -def _check_deprecation( - hass: HomeAssistant, - device: CustomerDevice, - description: SwitchEntityDescription, - entity_registry: er.EntityRegistry, -) -> bool: - """Check entity deprecation. - - Returns: - `True` if the entity should be created, `False` otherwise. - """ - # Not deprecated, just create it - if not isinstance(description, TuyaDeprecatedSwitchEntityDescription): - return True - - unique_id = f"tuya.{device.id}{description.key}" - entity_id = entity_registry.async_get_entity_id(SWITCH_DOMAIN, DOMAIN, unique_id) - - # Deprecated and not present in registry, skip creation - if not entity_id or not (entity_entry := entity_registry.async_get(entity_id)): - return False - - # Deprecated and present in registry but disabled, remove it and skip creation - if entity_entry.disabled: - entity_registry.async_remove(entity_id) - async_delete_issue( - hass, - DOMAIN, - f"deprecated_entity_{unique_id}", - ) - return False - - # Deprecated and present in registry and enabled, raise issue and create it - async_create_issue( - hass, - DOMAIN, - f"deprecated_entity_{unique_id}", - breaks_in_ha_version=description.breaks_in_ha_version, - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key=description.deprecated, - translation_placeholders={ - "name": f"{device.name} {entity_entry.name or entity_entry.original_name}", - "entity": entity_id, - }, - ) - return True - - class TuyaSwitchEntity(TuyaEntity, SwitchEntity): """Tuya Switch Device.""" @@ -1040,17 +960,19 @@ def is_on(self) -> bool | None: """Return true if switch is on.""" return self._read_wrapper(self._dpcode_wrapper) - async def _handle_state_update( + async def _process_device_update( self, - updated_status_properties: list[str] | None, + updated_status_properties: list[str], dp_timestamps: dict[str, int] | None, - ) -> None: - """Handle state update, only if this entity's dpcode was actually updated.""" - if self._dpcode_wrapper.skip_update( + ) -> bool: + """Called when Tuya device sends an update with updated properties. + + Returns True if the Home Assistant state should be written, + or False if the state write should be skipped. + """ + return not self._dpcode_wrapper.skip_update( self.device, updated_status_properties, dp_timestamps - ): - return - self.async_write_ha_state() + ) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" diff --git a/homeassistant/components/tuya/type_information.py b/homeassistant/components/tuya/type_information.py deleted file mode 100644 index a3a2122c055858..00000000000000 --- a/homeassistant/components/tuya/type_information.py +++ /dev/null @@ -1,302 +0,0 @@ -"""Type information classes for the Tuya integration.""" - -from __future__ import annotations - -import base64 -from dataclasses import dataclass -from typing import Any, ClassVar, Self, cast - -from tuya_sharing import CustomerDevice - -from homeassistant.util.json import json_loads_object - -from .const import LOGGER, DPType -from .util import parse_dptype - -# Dictionary to track logged warnings to avoid spamming logs -# Keyed by device ID -DEVICE_WARNINGS: dict[str, set[str]] = {} - - -def _should_log_warning(device_id: str, warning_key: str) -> bool: - """Check if a warning has already been logged for a device and add it if not. - - Returns: True if the warning should be logged, False if it was already logged. - """ - if (device_warnings := DEVICE_WARNINGS.get(device_id)) is None: - device_warnings = set() - DEVICE_WARNINGS[device_id] = device_warnings - if warning_key in device_warnings: - return False - DEVICE_WARNINGS[device_id].add(warning_key) - return True - - -@dataclass(kw_only=True) -class TypeInformation[T]: - """Type information. - - As provided by the SDK, from `device.function` / `device.status_range`. - """ - - _DPTYPE: ClassVar[DPType] - dpcode: str - type_data: str - - def process_raw_value( - self, raw_value: Any | None, device: CustomerDevice - ) -> T | None: - """Read and process raw value against this type information. - - Base implementation does no validation, subclasses may override to provide - specific validation. - """ - return raw_value - - @classmethod - def _from_json( - cls, dpcode: str, type_data: str, *, report_type: str | None - ) -> Self | None: - """Load JSON string and return a TypeInformation object.""" - return cls(dpcode=dpcode, type_data=type_data) - - @classmethod - def find_dpcode( - cls, - device: CustomerDevice, - dpcodes: str | tuple[str, ...] | None, - *, - prefer_function: bool = False, - ) -> Self | None: - """Find type information for a matching DP code available for this device.""" - if dpcodes is None: - return None - - if not isinstance(dpcodes, tuple): - dpcodes = (dpcodes,) - - lookup_tuple = ( - (device.function, device.status_range) - if prefer_function - else (device.status_range, device.function) - ) - - for dpcode in dpcodes: - report_type = ( - sr.report_type if (sr := device.status_range.get(dpcode)) else None - ) - for device_specs in lookup_tuple: - if ( - (current_definition := device_specs.get(dpcode)) - and parse_dptype(current_definition.type) is cls._DPTYPE - and ( - type_information := cls._from_json( - dpcode=dpcode, - type_data=current_definition.values, - report_type=report_type, - ) - ) - ): - return type_information - - return None - - -@dataclass(kw_only=True) -class BitmapTypeInformation(TypeInformation[int]): - """Bitmap type information.""" - - _DPTYPE = DPType.BITMAP - - label: list[str] - - @classmethod - def _from_json( - cls, dpcode: str, type_data: str, *, report_type: str | None - ) -> Self | None: - """Load JSON string and return a BitmapTypeInformation object.""" - if not (parsed := cast(dict[str, Any] | None, json_loads_object(type_data))): - return None - return cls( - dpcode=dpcode, - type_data=type_data, - label=parsed["label"], - ) - - -@dataclass(kw_only=True) -class BooleanTypeInformation(TypeInformation[bool]): - """Boolean type information.""" - - _DPTYPE = DPType.BOOLEAN - - def process_raw_value( - self, raw_value: Any | None, device: CustomerDevice - ) -> bool | None: - """Read and process raw value against this type information.""" - if raw_value is None: - return None - # Validate input against defined range - if raw_value not in (True, False): - if _should_log_warning( - device.id, f"boolean_out_range|{self.dpcode}|{raw_value}" - ): - LOGGER.warning( - "Found invalid boolean value `%s` for datapoint `%s` in product " - "id `%s`, expected one of `%s`; please report this defect to " - "Tuya support", - raw_value, - self.dpcode, - device.product_id, - (True, False), - ) - return None - return raw_value - - -@dataclass(kw_only=True) -class EnumTypeInformation(TypeInformation[str]): - """Enum type information.""" - - _DPTYPE = DPType.ENUM - - range: list[str] - - def process_raw_value( - self, raw_value: Any | None, device: CustomerDevice - ) -> str | None: - """Read and process raw value against this type information.""" - if raw_value is None: - return None - # Validate input against defined range - if raw_value not in self.range: - if _should_log_warning( - device.id, f"enum_out_range|{self.dpcode}|{raw_value}" - ): - LOGGER.warning( - "Found invalid enum value `%s` for datapoint `%s` in product " - "id `%s`, expected one of `%s`; please report this defect to " - "Tuya support", - raw_value, - self.dpcode, - device.product_id, - self.range, - ) - return None - return raw_value - - @classmethod - def _from_json( - cls, dpcode: str, type_data: str, *, report_type: str | None - ) -> Self | None: - """Load JSON string and return an EnumTypeInformation object.""" - if not (parsed := json_loads_object(type_data)): - return None - return cls( - dpcode=dpcode, - type_data=type_data, - **cast(dict[str, list[str]], parsed), - ) - - -@dataclass(kw_only=True) -class IntegerTypeInformation(TypeInformation[float]): - """Integer type information.""" - - _DPTYPE = DPType.INTEGER - - min: int - max: int - scale: int - step: int - unit: str | None = None - report_type: str | None - - def scale_value(self, value: int) -> float: - """Scale a value.""" - return value / (10**self.scale) - - def scale_value_back(self, value: float) -> int: - """Return raw value for scaled.""" - return round(value * (10**self.scale)) - - def process_raw_value( - self, raw_value: Any | None, device: CustomerDevice - ) -> float | None: - """Read and process raw value against this type information.""" - if raw_value is None: - return None - # Validate input against defined range - if not isinstance(raw_value, int) or not (self.min <= raw_value <= self.max): - if _should_log_warning( - device.id, f"integer_out_range|{self.dpcode}|{raw_value}" - ): - LOGGER.warning( - "Found invalid integer value `%s` for datapoint `%s` in product " - "id `%s`, expected integer value between %s and %s; please report " - "this defect to Tuya support", - raw_value, - self.dpcode, - device.product_id, - self.min, - self.max, - ) - - return None - return raw_value / (10**self.scale) - - @classmethod - def _from_json( - cls, dpcode: str, type_data: str, *, report_type: str | None - ) -> Self | None: - """Load JSON string and return an IntegerTypeInformation object.""" - if not (parsed := cast(dict[str, Any] | None, json_loads_object(type_data))): - return None - - return cls( - dpcode=dpcode, - type_data=type_data, - min=int(parsed["min"]), - max=int(parsed["max"]), - scale=int(parsed["scale"]), - step=int(parsed["step"]), - unit=parsed.get("unit"), - report_type=report_type, - ) - - -@dataclass(kw_only=True) -class JsonTypeInformation(TypeInformation[dict[str, Any]]): - """Json type information.""" - - _DPTYPE = DPType.JSON - - def process_raw_value( - self, raw_value: Any | None, device: CustomerDevice - ) -> dict[str, Any] | None: - """Read and process raw value against this type information.""" - if raw_value is None: - return None - return json_loads_object(raw_value) - - -@dataclass(kw_only=True) -class RawTypeInformation(TypeInformation[bytes]): - """Raw type information.""" - - _DPTYPE = DPType.RAW - - def process_raw_value( - self, raw_value: Any | None, device: CustomerDevice - ) -> bytes | None: - """Read and process raw value against this type information.""" - if raw_value is None: - return None - return base64.b64decode(raw_value) - - -@dataclass(kw_only=True) -class StringTypeInformation(TypeInformation[str]): - """String type information.""" - - _DPTYPE = DPType.STRING diff --git a/homeassistant/components/tuya/util.py b/homeassistant/components/tuya/util.py index 0b1b549d62a139..bf00f0c9d069f5 100644 --- a/homeassistant/components/tuya/util.py +++ b/homeassistant/components/tuya/util.py @@ -2,27 +2,11 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any - from tuya_sharing import CustomerDevice from homeassistant.exceptions import ServiceValidationError -from .const import DOMAIN, DPCode, DPType - -if TYPE_CHECKING: - from .type_information import IntegerTypeInformation - -_DPTYPE_MAPPING: dict[str, DPType] = { - "bitmap": DPType.BITMAP, - "bool": DPType.BOOLEAN, - "enum": DPType.ENUM, - "json": DPType.JSON, - "raw": DPType.RAW, - "string": DPType.STRING, - "value": DPType.INTEGER, -} +from .const import DOMAIN, DPCode def get_dpcode( @@ -46,90 +30,6 @@ def get_dpcode( return None -def parse_dptype(dptype: str) -> DPType | None: - """Parse DPType from device DPCode information.""" - try: - return DPType(dptype) - except ValueError: - # Sometimes, we get ill-formed DPTypes from the cloud, - # this fixes them and maps them to the correct DPType. - return _DPTYPE_MAPPING.get(dptype) - - -@dataclass(kw_only=True) -class RemapHelper: - """Helper class for remapping values.""" - - source_min: int - source_max: int - target_min: int - target_max: int - - @classmethod - def from_type_information( - cls, - type_information: IntegerTypeInformation, - target_min: int, - target_max: int, - ) -> RemapHelper: - """Create RemapHelper from IntegerTypeInformation.""" - return cls( - source_min=type_information.min, - source_max=type_information.max, - target_min=target_min, - target_max=target_max, - ) - - @classmethod - def from_function_data( - cls, function_data: dict[str, Any], target_min: int, target_max: int - ) -> RemapHelper: - """Create RemapHelper from function_data.""" - return cls( - source_min=function_data["min"], - source_max=function_data["max"], - target_min=target_min, - target_max=target_max, - ) - - def remap_value_to(self, value: float, *, reverse: bool = False) -> float: - """Remap a value from this range to a new range.""" - return self.remap_value( - value, - self.source_min, - self.source_max, - self.target_min, - self.target_max, - reverse=reverse, - ) - - def remap_value_from(self, value: float, *, reverse: bool = False) -> float: - """Remap a value from its current range to this range.""" - return self.remap_value( - value, - self.target_min, - self.target_max, - self.source_min, - self.source_max, - reverse=reverse, - ) - - @staticmethod - def remap_value( - value: float, - from_min: float, - from_max: float, - to_min: float, - to_max: float, - *, - reverse: bool = False, - ) -> float: - """Remap a value from its current range, to a new range.""" - if reverse: - value = from_max - value + from_min - return ((value - from_min) / (from_max - from_min)) * (to_max - to_min) + to_min - - class ActionDPCodeNotFoundError(ServiceValidationError): """Custom exception for action DP code not found errors.""" diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index a9ce6b7044f6c0..09440848572b99 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -2,8 +2,18 @@ from __future__ import annotations -from typing import Any, Self +from typing import Any +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.common import DPCodeEnumWrapper +from tuya_device_handlers.device_wrapper.vacuum import ( + VacuumActionWrapper, + VacuumActivityWrapper, +) +from tuya_device_handlers.helpers.homeassistant import ( + TuyaVacuumAction, + TuyaVacuumActivity, +) from tuya_sharing import CustomerDevice, Manager from homeassistant.components.vacuum import ( @@ -18,140 +28,15 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -from .models import DeviceWrapper, DPCodeBooleanWrapper, DPCodeEnumWrapper - - -class _VacuumActivityWrapper(DeviceWrapper): - """Wrapper for the state of a device.""" - - _TUYA_STATUS_TO_HA = { - "charge_done": VacuumActivity.DOCKED, - "chargecompleted": VacuumActivity.DOCKED, - "chargego": VacuumActivity.DOCKED, - "charging": VacuumActivity.DOCKED, - "cleaning": VacuumActivity.CLEANING, - "docking": VacuumActivity.RETURNING, - "goto_charge": VacuumActivity.RETURNING, - "goto_pos": VacuumActivity.CLEANING, - "mop_clean": VacuumActivity.CLEANING, - "part_clean": VacuumActivity.CLEANING, - "paused": VacuumActivity.PAUSED, - "pick_zone_clean": VacuumActivity.CLEANING, - "pos_arrived": VacuumActivity.CLEANING, - "pos_unarrive": VacuumActivity.CLEANING, - "random": VacuumActivity.CLEANING, - "sleep": VacuumActivity.IDLE, - "smart_clean": VacuumActivity.CLEANING, - "smart": VacuumActivity.CLEANING, - "spot_clean": VacuumActivity.CLEANING, - "standby": VacuumActivity.IDLE, - "wall_clean": VacuumActivity.CLEANING, - "wall_follow": VacuumActivity.CLEANING, - "zone_clean": VacuumActivity.CLEANING, - } - - def __init__( - self, - pause_wrapper: DPCodeBooleanWrapper | None = None, - status_wrapper: DPCodeEnumWrapper | None = None, - ) -> None: - """Init _VacuumActivityWrapper.""" - self._pause_wrapper = pause_wrapper - self._status_wrapper = status_wrapper - - @classmethod - def find_dpcode(cls, device: CustomerDevice) -> Self | None: - """Find and return a _VacuumActivityWrapper for the given DP codes.""" - pause_wrapper = DPCodeBooleanWrapper.find_dpcode(device, DPCode.PAUSE) - status_wrapper = DPCodeEnumWrapper.find_dpcode(device, DPCode.STATUS) - if pause_wrapper or status_wrapper: - return cls(pause_wrapper=pause_wrapper, status_wrapper=status_wrapper) - return None - - def read_device_status(self, device: CustomerDevice) -> VacuumActivity | None: - """Read the device status.""" - if ( - self._status_wrapper - and (status := self._status_wrapper.read_device_status(device)) is not None - ): - return self._TUYA_STATUS_TO_HA.get(status) - - if self._pause_wrapper and self._pause_wrapper.read_device_status(device): - return VacuumActivity.PAUSED - return None - - -class _VacuumActionWrapper(DeviceWrapper): - """Wrapper for sending actions to a vacuum.""" - _TUYA_MODE_RETURN_HOME = "chargego" - - def __init__( - self, - charge_wrapper: DPCodeBooleanWrapper | None, - locate_wrapper: DPCodeBooleanWrapper | None, - pause_wrapper: DPCodeBooleanWrapper | None, - mode_wrapper: DPCodeEnumWrapper | None, - switch_wrapper: DPCodeBooleanWrapper | None, - ) -> None: - """Init _VacuumActionWrapper.""" - self._charge_wrapper = charge_wrapper - self._locate_wrapper = locate_wrapper - self._mode_wrapper = mode_wrapper - self._switch_wrapper = switch_wrapper - - self.options = [] - if charge_wrapper or ( - mode_wrapper and self._TUYA_MODE_RETURN_HOME in mode_wrapper.options - ): - self.options.append("return_to_base") - if locate_wrapper: - self.options.append("locate") - if pause_wrapper: - self.options.append("pause") - if switch_wrapper: - self.options.append("start") - self.options.append("stop") - - @classmethod - def find_dpcode(cls, device: CustomerDevice) -> Self: - """Find and return a _VacuumActionWrapper for the given DP codes.""" - return cls( - charge_wrapper=DPCodeBooleanWrapper.find_dpcode( - device, DPCode.SWITCH_CHARGE, prefer_function=True - ), - locate_wrapper=DPCodeBooleanWrapper.find_dpcode( - device, DPCode.SEEK, prefer_function=True - ), - mode_wrapper=DPCodeEnumWrapper.find_dpcode( - device, DPCode.MODE, prefer_function=True - ), - pause_wrapper=DPCodeBooleanWrapper.find_dpcode(device, DPCode.PAUSE), - switch_wrapper=DPCodeBooleanWrapper.find_dpcode( - device, DPCode.POWER_GO, prefer_function=True - ), - ) - - def get_update_commands( - self, device: CustomerDevice, value: Any - ) -> list[dict[str, Any]]: - """Get the commands for the action wrapper.""" - if value == "locate" and self._locate_wrapper: - return self._locate_wrapper.get_update_commands(device, True) - if value == "pause" and self._switch_wrapper: - return self._switch_wrapper.get_update_commands(device, False) - if value == "return_to_base": - if self._charge_wrapper: - return self._charge_wrapper.get_update_commands(device, True) - if self._mode_wrapper: - return self._mode_wrapper.get_update_commands( - device, self._TUYA_MODE_RETURN_HOME - ) - if value == "start" and self._switch_wrapper: - return self._switch_wrapper.get_update_commands(device, True) - if value == "stop" and self._switch_wrapper: - return self._switch_wrapper.get_update_commands(device, False) - return [] +_TUYA_TO_HA_ACTIVITY_MAPPINGS = { + TuyaVacuumActivity.CLEANING: VacuumActivity.CLEANING, + TuyaVacuumActivity.DOCKED: VacuumActivity.DOCKED, + TuyaVacuumActivity.IDLE: VacuumActivity.IDLE, + TuyaVacuumActivity.PAUSED: VacuumActivity.PAUSED, + TuyaVacuumActivity.RETURNING: VacuumActivity.RETURNING, + TuyaVacuumActivity.ERROR: VacuumActivity.ERROR, +} async def async_setup_entry( @@ -173,8 +58,8 @@ def async_discover_device(device_ids: list[str]) -> None: TuyaVacuumEntity( device, manager, - action_wrapper=_VacuumActionWrapper.find_dpcode(device), - activity_wrapper=_VacuumActivityWrapper.find_dpcode(device), + action_wrapper=VacuumActionWrapper.find_dpcode(device), + activity_wrapper=VacuumActivityWrapper.find_dpcode(device), fan_speed_wrapper=DPCodeEnumWrapper.find_dpcode( device, DPCode.SUCTION, prefer_function=True ), @@ -199,8 +84,8 @@ def __init__( device: CustomerDevice, device_manager: Manager, *, - action_wrapper: DeviceWrapper[str] | None, - activity_wrapper: DeviceWrapper[VacuumActivity] | None, + action_wrapper: DeviceWrapper[TuyaVacuumAction] | None, + activity_wrapper: DeviceWrapper[TuyaVacuumActivity] | None, fan_speed_wrapper: DeviceWrapper[str] | None, ) -> None: """Init Tuya vacuum.""" @@ -213,15 +98,15 @@ def __init__( self._attr_supported_features = VacuumEntityFeature.SEND_COMMAND if action_wrapper: - if "pause" in action_wrapper.options: + if TuyaVacuumAction.PAUSE in action_wrapper.options: self._attr_supported_features |= VacuumEntityFeature.PAUSE - if "return_to_base" in action_wrapper.options: + if TuyaVacuumAction.RETURN_TO_BASE in action_wrapper.options: self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME - if "locate" in action_wrapper.options: + if TuyaVacuumAction.LOCATE in action_wrapper.options: self._attr_supported_features |= VacuumEntityFeature.LOCATE - if "start" in action_wrapper.options: + if TuyaVacuumAction.START in action_wrapper.options: self._attr_supported_features |= VacuumEntityFeature.START - if "stop" in action_wrapper.options: + if TuyaVacuumAction.STOP in action_wrapper.options: self._attr_supported_features |= VacuumEntityFeature.STOP if activity_wrapper: @@ -239,27 +124,38 @@ def fan_speed(self) -> str | None: @property def activity(self) -> VacuumActivity | None: """Return Tuya vacuum device state.""" - return self._read_wrapper(self._activity_wrapper) + tuya_value = self._read_wrapper(self._activity_wrapper) + return _TUYA_TO_HA_ACTIVITY_MAPPINGS.get(tuya_value) if tuya_value else None async def async_start(self, **kwargs: Any) -> None: """Start the device.""" - await self._async_send_wrapper_updates(self._action_wrapper, "start") + await self._async_send_wrapper_updates( + self._action_wrapper, TuyaVacuumAction.START + ) async def async_stop(self, **kwargs: Any) -> None: """Stop the device.""" - await self._async_send_wrapper_updates(self._action_wrapper, "stop") + await self._async_send_wrapper_updates( + self._action_wrapper, TuyaVacuumAction.STOP + ) async def async_pause(self, **kwargs: Any) -> None: """Pause the device.""" - await self._async_send_wrapper_updates(self._action_wrapper, "pause") + await self._async_send_wrapper_updates( + self._action_wrapper, TuyaVacuumAction.PAUSE + ) async def async_return_to_base(self, **kwargs: Any) -> None: """Return device to dock.""" - await self._async_send_wrapper_updates(self._action_wrapper, "return_to_base") + await self._async_send_wrapper_updates( + self._action_wrapper, TuyaVacuumAction.RETURN_TO_BASE + ) async def async_locate(self, **kwargs: Any) -> None: """Locate the device.""" - await self._async_send_wrapper_updates(self._action_wrapper, "locate") + await self._async_send_wrapper_updates( + self._action_wrapper, TuyaVacuumAction.LOCATE + ) async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set fan speed.""" diff --git a/homeassistant/components/tuya/valve.py b/homeassistant/components/tuya/valve.py index 01bf0f054f68c6..fc9ccbd9700147 100644 --- a/homeassistant/components/tuya/valve.py +++ b/homeassistant/components/tuya/valve.py @@ -2,6 +2,8 @@ from __future__ import annotations +from tuya_device_handlers.device_wrapper.base import DeviceWrapper +from tuya_device_handlers.device_wrapper.common import DPCodeBooleanWrapper from tuya_sharing import CustomerDevice, Manager from homeassistant.components.valve import ( @@ -17,7 +19,6 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -from .models import DeviceWrapper, DPCodeBooleanWrapper VALVES: dict[DeviceCategory, tuple[ValveEntityDescription, ...]] = { DeviceCategory.SFKZQ: ( @@ -137,17 +138,19 @@ def is_closed(self) -> bool | None: return None return not is_open - async def _handle_state_update( + async def _process_device_update( self, - updated_status_properties: list[str] | None, + updated_status_properties: list[str], dp_timestamps: dict[str, int] | None, - ) -> None: - """Handle state update, only if this entity's dpcode was actually updated.""" - if self._dpcode_wrapper.skip_update( + ) -> bool: + """Called when Tuya device sends an update with updated properties. + + Returns True if the Home Assistant state should be written, + or False if the state write should be skipped. + """ + return not self._dpcode_wrapper.skip_update( self.device, updated_status_properties, dp_timestamps - ): - return - self.async_write_ha_state() + ) async def async_open_valve(self) -> None: """Open the valve.""" diff --git a/homeassistant/components/twilio/manifest.json b/homeassistant/components/twilio/manifest.json index 3e54541c7aff88..d24f4fa3953c67 100644 --- a/homeassistant/components/twilio/manifest.json +++ b/homeassistant/components/twilio/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["webhook"], "documentation": "https://www.home-assistant.io/integrations/twilio", + "integration_type": "service", "iot_class": "cloud_push", "loggers": ["twilio"], "requirements": ["twilio==6.32.0"] diff --git a/homeassistant/components/twilio/strings.json b/homeassistant/components/twilio/strings.json index 00fc168fc055ba..f7a031b9d9ce42 100644 --- a/homeassistant/components/twilio/strings.json +++ b/homeassistant/components/twilio/strings.json @@ -2,6 +2,7 @@ "config": { "abort": { "cloud_not_connected": "[%key:common::config_flow::abort::cloud_not_connected%]", + "reconfigure_successful": "**Reconfiguration was successful**\n\nGo to [webhooks in Twilio]({twilio_url}) and update the webhook with the following settings:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data.", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]" }, @@ -9,6 +10,10 @@ "default": "To send events to Home Assistant, you will need to set up a [webhook with Twilio]({twilio_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." }, "step": { + "reconfigure": { + "description": "Do you want to start reconfiguration?", + "title": "Reconfigure Twilio webhook" + }, "user": { "description": "[%key:common::config_flow::description::confirm_setup%]", "title": "Set up the Twilio webhook" diff --git a/homeassistant/components/twinkly/manifest.json b/homeassistant/components/twinkly/manifest.json index a84eebf0f2807a..78f3308e4010e7 100644 --- a/homeassistant/components/twinkly/manifest.json +++ b/homeassistant/components/twinkly/manifest.json @@ -12,6 +12,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/twinkly", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["ttls"], "requirements": ["ttls==1.8.3"] diff --git a/homeassistant/components/twitch/manifest.json b/homeassistant/components/twitch/manifest.json index 12ae1d1ee72a77..553395c1aa4a24 100644 --- a/homeassistant/components/twitch/manifest.json +++ b/homeassistant/components/twitch/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/twitch", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["twitch"], "requirements": ["twitchAPI==4.2.1"] diff --git a/homeassistant/components/uhoo/__init__.py b/homeassistant/components/uhoo/__init__.py index 1b9a223efb52a1..898d1a8583a1af 100644 --- a/homeassistant/components/uhoo/__init__.py +++ b/homeassistant/components/uhoo/__init__.py @@ -3,11 +3,11 @@ from aiodns.error import DNSError from aiohttp.client_exceptions import ClientConnectionError from uhooapi import Client -from uhooapi.errors import UhooError, UnauthorizedError +from uhooapi.errors import ForbiddenError, UhooError, UnauthorizedError from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import PLATFORMS @@ -28,8 +28,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: UhooConfigEntry) await client.setup_devices() except (ClientConnectionError, DNSError) as err: raise ConfigEntryNotReady(f"Cannot connect to uHoo servers: {err}") from err - except UnauthorizedError as err: - raise ConfigEntryError(f"Invalid API credentials: {err}") from err + except (UnauthorizedError, ForbiddenError) as err: + raise ConfigEntryAuthFailed(f"Invalid API credentials: {err}") from err except UhooError as err: raise ConfigEntryNotReady(err) from err diff --git a/homeassistant/components/uhoo/config_flow.py b/homeassistant/components/uhoo/config_flow.py index dbaa8d1c6ad96a..348f22e7069c9d 100644 --- a/homeassistant/components/uhoo/config_flow.py +++ b/homeassistant/components/uhoo/config_flow.py @@ -1,9 +1,10 @@ """Custom uhoo config flow setup.""" +from collections.abc import Mapping from typing import Any from uhooapi import Client -from uhooapi.errors import UhooError, UnauthorizedError +from uhooapi.errors import ForbiddenError, UhooError, UnauthorizedError import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -45,7 +46,7 @@ async def async_step_user( client = Client(user_input[CONF_API_KEY], session, debug=True) try: await client.login() - except UnauthorizedError: + except UnauthorizedError, ForbiddenError: errors["base"] = "invalid_auth" except UhooError: errors["base"] = "cannot_connect" @@ -65,3 +66,39 @@ async def async_step_user( ), errors=errors, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauthentication upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauthentication dialog.""" + errors: dict[str, str] = {} + if user_input is not None: + session = async_create_clientsession(self.hass) + client = Client(user_input[CONF_API_KEY], session, debug=True) + try: + await client.login() + except UnauthorizedError, ForbiddenError: + errors["base"] = "invalid_auth" + except UhooError: + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates=user_input, + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + USER_DATA_SCHEMA, user_input + ), + errors=errors, + ) diff --git a/homeassistant/components/uhoo/const.py b/homeassistant/components/uhoo/const.py index 3666ab0d0b4363..a725a45ff5fe10 100644 --- a/homeassistant/components/uhoo/const.py +++ b/homeassistant/components/uhoo/const.py @@ -15,6 +15,7 @@ API_VIRUS = "virus_index" API_MOLD = "mold_index" +API_INFLUENZA = "influenza_index" API_TEMP = "temperature" API_HUMIDITY = "humidity" API_PM25 = "pm25" diff --git a/homeassistant/components/uhoo/coordinator.py b/homeassistant/components/uhoo/coordinator.py index da42fb2c88a87e..004299239d016c 100644 --- a/homeassistant/components/uhoo/coordinator.py +++ b/homeassistant/components/uhoo/coordinator.py @@ -1,10 +1,11 @@ """Custom uhoo data update coordinator.""" from uhooapi import Client, Device -from uhooapi.errors import UhooError +from uhooapi.errors import ForbiddenError, UhooError, UnauthorizedError 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, LOGGER, UPDATE_INTERVAL @@ -34,6 +35,8 @@ async def _async_update_data(self) -> dict[str, Device]: if self.client.devices: for device_id in self.client.devices: await self.client.get_latest_data(device_id) + except (UnauthorizedError, ForbiddenError) as error: + raise ConfigEntryAuthFailed(f"Invalid API credentials: {error}") from error except UhooError as error: raise UpdateFailed(f"The device is unavailable: {error}") from error else: diff --git a/homeassistant/components/uhoo/manifest.json b/homeassistant/components/uhoo/manifest.json index 28b729984eda10..5677840e469121 100644 --- a/homeassistant/components/uhoo/manifest.json +++ b/homeassistant/components/uhoo/manifest.json @@ -4,7 +4,8 @@ "codeowners": ["@getuhoo", "@joshsmonta"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/uhooair", + "integration_type": "hub", "iot_class": "cloud_polling", - "quality_scale": "bronze", - "requirements": ["uhooapi==1.2.6"] + "quality_scale": "silver", + "requirements": ["uhooapi==1.2.8"] } diff --git a/homeassistant/components/uhoo/quality_scale.yaml b/homeassistant/components/uhoo/quality_scale.yaml index 5a63545c164c5d..4403287227fa9a 100644 --- a/homeassistant/components/uhoo/quality_scale.yaml +++ b/homeassistant/components/uhoo/quality_scale.yaml @@ -26,9 +26,9 @@ rules: docs-installation-parameters: done entity-unavailable: done integration-owner: done - log-when-unavailable: todo + log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/uhoo/sensor.py b/homeassistant/components/uhoo/sensor.py index eed8cd4195ede2..e154a566ce647e 100644 --- a/homeassistant/components/uhoo/sensor.py +++ b/homeassistant/components/uhoo/sensor.py @@ -29,6 +29,7 @@ API_CO, API_CO2, API_HUMIDITY, + API_INFLUENZA, API_MOLD, API_NO2, API_OZONE, @@ -130,6 +131,12 @@ class UhooSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.mold_index, ), + UhooSensorEntityDescription( + key=API_INFLUENZA, + translation_key=API_INFLUENZA, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.influenza_index, + ), ) diff --git a/homeassistant/components/uhoo/strings.json b/homeassistant/components/uhoo/strings.json index d9da4499a025f0..56086e9d46b1c9 100644 --- a/homeassistant/components/uhoo/strings.json +++ b/homeassistant/components/uhoo/strings.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -9,6 +10,14 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "Your uHoo API key. You can find this in your uHoo account settings." + } + }, "user": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]" @@ -23,6 +32,9 @@ }, "entity": { "sensor": { + "influenza_index": { + "name": "Influenza index" + }, "mold_index": { "name": "Mold index" }, diff --git a/homeassistant/components/ukraine_alarm/manifest.json b/homeassistant/components/ukraine_alarm/manifest.json index 3c0a07c41dbdfa..3bb66f21c7a9fb 100644 --- a/homeassistant/components/ukraine_alarm/manifest.json +++ b/homeassistant/components/ukraine_alarm/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@PaulAnnekov"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ukraine_alarm", + "integration_type": "service", "iot_class": "cloud_polling", "requirements": ["uasiren==0.0.1"] } diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index 71404ef4bc2a6c..15b0fbafead2ec 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -76,9 +76,7 @@ async def async_remove_config_entry_device( """Remove config entry from a device.""" hub = config_entry.runtime_data return not any( - identifier - for _, identifier in device_entry.connections - if identifier in hub.api.clients or identifier in hub.api.devices + identifier in hub.api.devices for _, identifier in device_entry.connections ) diff --git a/homeassistant/components/unifi/icons.json b/homeassistant/components/unifi/icons.json index 97ff2f734a3dcd..ba94eabcc93ca6 100644 --- a/homeassistant/components/unifi/icons.json +++ b/homeassistant/components/unifi/icons.json @@ -38,6 +38,9 @@ "port_bandwidth_tx": { "default": "mdi:upload" }, + "port_link_speed": { + "default": "mdi:speedometer" + }, "wlan_clients": { "default": "mdi:account-multiple" } diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 898a59d951b8cc..7a161a9d7c2ce2 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -485,6 +485,23 @@ class UnifiSensorEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( unique_id_fn=lambda hub, obj_id: f"port_tx-{obj_id}", value_fn=lambda hub, port: port.tx_bytes_r, ), + UnifiSensorEntityDescription[Ports, Port]( + key="Port speed", + translation_key="port_link_speed", + device_class=SensorDeviceClass.DATA_RATE, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, + suggested_display_precision=0, + entity_registry_enabled_default=False, + 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, + unique_id_fn=lambda hub, obj_id: f"port_link_speed-{obj_id}", + value_fn=lambda hub, port: port.raw.get("speed", 0), + ), UnifiSensorEntityDescription[Clients, Client]( key="Client uptime", device_class=SensorDeviceClass.TIMESTAMP, diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json index 084aa3e4fd7649..ef6a7c1d42ce84 100644 --- a/homeassistant/components/unifi/strings.json +++ b/homeassistant/components/unifi/strings.json @@ -56,6 +56,9 @@ "upgrading": "Upgrading" } }, + "port_link_speed": { + "name": "Link speed" + }, "wired_client_link_speed": { "name": "Link speed" } diff --git a/homeassistant/components/unifi_access/__init__.py b/homeassistant/components/unifi_access/__init__.py new file mode 100644 index 00000000000000..0c5c0930edd08b --- /dev/null +++ b/homeassistant/components/unifi_access/__init__.py @@ -0,0 +1,59 @@ +"""The UniFi Access integration.""" + +from __future__ import annotations + +from unifi_access_api import ApiAuthError, ApiConnectionError, UnifiAccessApiClient + +from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_VERIFY_SSL, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator + +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.EVENT, + Platform.SWITCH, +] + + +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]) + + client = UnifiAccessApiClient( + host=entry.data[CONF_HOST], + api_token=entry.data[CONF_API_TOKEN], + session=session, + verify_ssl=entry.data[CONF_VERIFY_SSL], + ) + + try: + await client.authenticate() + except ApiAuthError as err: + raise ConfigEntryNotReady( + f"Authentication failed for UniFi Access at {entry.data[CONF_HOST]}" + ) from err + except ApiConnectionError as err: + raise ConfigEntryNotReady( + f"Unable to connect to UniFi Access at {entry.data[CONF_HOST]}" + ) from err + + coordinator = UnifiAccessCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + entry.async_on_unload(client.close) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: UnifiAccessConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/unifi_access/binary_sensor.py b/homeassistant/components/unifi_access/binary_sensor.py new file mode 100644 index 00000000000000..a59dc4d2b1c881 --- /dev/null +++ b/homeassistant/components/unifi_access/binary_sensor.py @@ -0,0 +1,50 @@ +"""Binary sensor platform for the UniFi Access integration.""" + +from __future__ import annotations + +from unifi_access_api import Door, DoorPositionStatus + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator +from .entity import UnifiAccessEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: UnifiAccessConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> 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() + ) + + +class UnifiAccessDoorPositionBinarySensor(UnifiAccessEntity, BinarySensorEntity): + """Representation of a UniFi Access door position binary sensor.""" + + _attr_name = None + _attr_device_class = BinarySensorDeviceClass.DOOR + + def __init__( + self, + coordinator: UnifiAccessCoordinator, + door: Door, + ) -> None: + """Initialize the binary sensor entity.""" + super().__init__(coordinator, door, "access_door_dps") + + @property + def is_on(self) -> bool: + """Return whether the door is open.""" + return self._door.door_position_status == DoorPositionStatus.OPEN diff --git a/homeassistant/components/unifi_access/button.py b/homeassistant/components/unifi_access/button.py new file mode 100644 index 00000000000000..d1c795006cf682 --- /dev/null +++ b/homeassistant/components/unifi_access/button.py @@ -0,0 +1,53 @@ +"""Button platform for the UniFi Access integration.""" + +from __future__ import annotations + +from unifi_access_api import Door, UnifiAccessError + +from homeassistant.components.button import ButtonEntity +from homeassistant.core import HomeAssistant +from homeassistant.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 button entities.""" + coordinator = entry.runtime_data + async_add_entities( + UnifiAccessUnlockButton(coordinator, door) + for door in coordinator.data.doors.values() + ) + + +class UnifiAccessUnlockButton(UnifiAccessEntity, ButtonEntity): + """Representation of a UniFi Access door unlock button.""" + + _attr_translation_key = "unlock" + + def __init__( + self, + coordinator: UnifiAccessCoordinator, + door: Door, + ) -> None: + """Initialize the button entity.""" + super().__init__(coordinator, door, "unlock") + + async def async_press(self) -> None: + """Unlock the door.""" + try: + await self.coordinator.client.unlock_door(self._door_id) + except UnifiAccessError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unlock_failed", + ) from err diff --git a/homeassistant/components/unifi_access/config_flow.py b/homeassistant/components/unifi_access/config_flow.py new file mode 100644 index 00000000000000..08cb9e9d35801a --- /dev/null +++ b/homeassistant/components/unifi_access/config_flow.py @@ -0,0 +1,68 @@ +"""Config flow for UniFi Access integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from unifi_access_api import ApiAuthError, ApiConnectionError, UnifiAccessApiClient +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_VERIFY_SSL +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class UnifiAccessConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for UniFi Access.""" + + VERSION = 1 + MINOR_VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + 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]}) + return self.async_create_entry( + title="UniFi Access", + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_API_TOKEN): str, + vol.Required(CONF_VERIFY_SSL, default=False): bool, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/unifi_access/const.py b/homeassistant/components/unifi_access/const.py new file mode 100644 index 00000000000000..36ac8fee8f9b68 --- /dev/null +++ b/homeassistant/components/unifi_access/const.py @@ -0,0 +1,3 @@ +"""Constants for the UniFi Access integration.""" + +DOMAIN = "unifi_access" diff --git a/homeassistant/components/unifi_access/coordinator.py b/homeassistant/components/unifi_access/coordinator.py new file mode 100644 index 00000000000000..756e694b22e0e3 --- /dev/null +++ b/homeassistant/components/unifi_access/coordinator.py @@ -0,0 +1,240 @@ +"""Data update coordinator for the UniFi Access integration.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Any, cast + +from unifi_access_api import ( + ApiAuthError, + ApiConnectionError, + ApiError, + Door, + EmergencyStatus, + UnifiAccessApiClient, + WsMessageHandler, +) +from unifi_access_api.models.websocket import ( + HwDoorbell, + InsightsAdd, + LocationUpdateState, + LocationUpdateV2, + SettingUpdate, + V2LocationState, + V2LocationUpdate, + WebsocketMessage, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type UnifiAccessConfigEntry = ConfigEntry[UnifiAccessCoordinator] + + +@dataclass(frozen=True) +class DoorEvent: + """Represent a door event from WebSocket.""" + + door_id: str + category: str + event_type: str + event_data: dict[str, Any] + + +@dataclass(frozen=True) +class UnifiAccessData: + """Data provided by the UniFi Access coordinator.""" + + doors: dict[str, Door] + emergency: EmergencyStatus + + +class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]): + """Coordinator for fetching UniFi Access door data.""" + + config_entry: UnifiAccessConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: UnifiAccessConfigEntry, + client: UnifiAccessApiClient, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=None, + ) + self.client = client + self._event_listeners: list[Callable[[DoorEvent], None]] = [] + + @callback + def async_subscribe_door_events( + self, + event_callback: Callable[[DoorEvent], None], + ) -> CALLBACK_TYPE: + """Subscribe to door events (doorbell, access).""" + + def _unsubscribe() -> None: + self._event_listeners.remove(event_callback) + + self._event_listeners.append(event_callback) + return _unsubscribe + + 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.hw.door_bell": self._handle_doorbell, + "access.logs.insights.add": self._handle_insights_add, + "access.data.setting.update": self._handle_setting_update, + } + self.client.start_websocket( + handlers, + on_connect=self._on_ws_connect, + on_disconnect=self._on_ws_disconnect, + ) + + async def _async_update_data(self) -> UnifiAccessData: + """Fetch all doors and emergency status from the API.""" + try: + async with asyncio.timeout(10): + doors, emergency = await asyncio.gather( + self.client.get_doors(), + self.client.get_emergency_status(), + ) + except ApiAuthError as err: + raise UpdateFailed(f"Authentication failed: {err}") from err + except ApiConnectionError as err: + raise UpdateFailed(f"Error connecting to API: {err}") from err + except ApiError as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + except TimeoutError as err: + raise UpdateFailed("Timeout communicating with UniFi Access API") from err + return UnifiAccessData( + doors={door.id: door for door in doors}, + emergency=emergency, + ) + + def _on_ws_connect(self) -> None: + """Handle WebSocket connection established.""" + _LOGGER.debug("WebSocket connected to UniFi Access") + if not self.last_update_success: + self.config_entry.async_create_background_task( + self.hass, + self.async_request_refresh(), + "unifi_access_reconnect_refresh", + ) + + def _on_ws_disconnect(self) -> None: + """Handle WebSocket disconnection.""" + _LOGGER.warning("WebSocket disconnected from UniFi Access") + self.async_set_update_error( + UpdateFailed("WebSocket disconnected from UniFi Access") + ) + + async def _handle_location_update(self, msg: WebsocketMessage) -> None: + """Handle location_update_v2 messages.""" + update = cast(LocationUpdateV2, msg) + self._process_door_update(update.data.id, update.data.state) + + 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) + + def _process_door_update( + self, door_id: str, ws_state: LocationUpdateState | V2LocationState | None + ) -> None: + """Process a door state update from WebSocket.""" + if self.data is None or door_id not in self.data.doors: + return + + if ws_state is None: + return + + current_door = self.data.doors[door_id] + updates: dict[str, object] = {} + if ws_state.dps is not None: + updates["door_position_status"] = ws_state.dps + if ws_state.lock == "locked": + updates["door_lock_relay_status"] = "lock" + elif ws_state.lock == "unlocked": + updates["door_lock_relay_status"] = "unlock" + if not updates: + return + updated_door = current_door.with_updates(**updates) + self.async_set_updated_data( + UnifiAccessData( + doors={**self.data.doors, door_id: updated_door}, + emergency=self.data.emergency, + ) + ) + + async def _handle_setting_update(self, msg: WebsocketMessage) -> None: + """Handle settings update messages (evacuation/lockdown).""" + if self.data is None: + return + update = cast(SettingUpdate, msg) + self.async_set_updated_data( + UnifiAccessData( + doors=self.data.doors, + emergency=EmergencyStatus( + evacuation=update.data.evacuation, + lockdown=update.data.lockdown, + ), + ) + ) + + async def _handle_doorbell(self, msg: WebsocketMessage) -> None: + """Handle doorbell press events.""" + doorbell = cast(HwDoorbell, msg) + self._dispatch_door_event( + doorbell.data.door_id, + "doorbell", + "ring", + {}, + ) + + async def _handle_insights_add(self, msg: WebsocketMessage) -> None: + """Handle access insights events (entry/exit).""" + insights = cast(InsightsAdd, msg) + door = insights.data.metadata.door + if not door.id: + return + event_type = ( + "access_granted" if insights.data.result == "ACCESS" else "access_denied" + ) + attrs: dict[str, Any] = {} + if insights.data.metadata.actor.display_name: + attrs["actor"] = insights.data.metadata.actor.display_name + if insights.data.metadata.authentication.display_name: + attrs["authentication"] = insights.data.metadata.authentication.display_name + if insights.data.result: + attrs["result"] = insights.data.result + self._dispatch_door_event(door.id, "access", event_type, attrs) + + @callback + def _dispatch_door_event( + self, + door_id: str, + category: str, + event_type: str, + event_data: dict[str, Any], + ) -> None: + """Dispatch a door event to all subscribed listeners.""" + event = DoorEvent(door_id, category, event_type, event_data) + for listener in self._event_listeners: + listener(event) diff --git a/homeassistant/components/unifi_access/entity.py b/homeassistant/components/unifi_access/entity.py new file mode 100644 index 00000000000000..29b993caedbcea --- /dev/null +++ b/homeassistant/components/unifi_access/entity.py @@ -0,0 +1,58 @@ +"""Base entity for the UniFi Access integration.""" + +from __future__ import annotations + +from unifi_access_api import Door + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import UnifiAccessCoordinator + + +class UnifiAccessEntity(CoordinatorEntity[UnifiAccessCoordinator]): + """Base entity for UniFi Access doors.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: UnifiAccessCoordinator, + door: Door, + key: str, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._door_id = door.id + self._attr_unique_id = f"{door.id}-{key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, door.id)}, + name=door.name, + manufacturer="Ubiquiti", + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self._door_id in self.coordinator.data.doors + + @property + def _door(self) -> Door: + """Return the current door state from coordinator data.""" + return self.coordinator.data.doors[self._door_id] + + +class UnifiAccessHubEntity(CoordinatorEntity[UnifiAccessCoordinator]): + """Base entity for hub-level (controller-wide) UniFi Access entities.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: UnifiAccessCoordinator) -> None: + """Initialize the hub entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + name="UniFi Access", + manufacturer="Ubiquiti", + ) diff --git a/homeassistant/components/unifi_access/event.py b/homeassistant/components/unifi_access/event.py new file mode 100644 index 00000000000000..3d86cd90863de6 --- /dev/null +++ b/homeassistant/components/unifi_access/event.py @@ -0,0 +1,96 @@ +"""Event platform for the UniFi Access integration.""" + +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.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import DoorEvent, UnifiAccessConfigEntry, UnifiAccessCoordinator +from .entity import UnifiAccessEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class UnifiAccessEventEntityDescription(EventEntityDescription): + """Describes a UniFi Access event entity.""" + + category: str + + +DOORBELL_EVENT_DESCRIPTION = UnifiAccessEventEntityDescription( + key="doorbell", + translation_key="doorbell", + device_class=EventDeviceClass.DOORBELL, + event_types=["ring"], + category="doorbell", +) + +ACCESS_EVENT_DESCRIPTION = UnifiAccessEventEntityDescription( + key="access", + translation_key="access", + event_types=["access_granted", "access_denied"], + category="access", +) + +EVENT_DESCRIPTIONS: list[UnifiAccessEventEntityDescription] = [ + DOORBELL_EVENT_DESCRIPTION, + ACCESS_EVENT_DESCRIPTION, +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: UnifiAccessConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up UniFi Access event entities.""" + coordinator = entry.runtime_data + async_add_entities( + UnifiAccessEventEntity(coordinator, door_id, description) + for door_id in coordinator.data.doors + for description in EVENT_DESCRIPTIONS + ) + + +class UnifiAccessEventEntity(UnifiAccessEntity, EventEntity): + """Representation of a UniFi Access event entity.""" + + entity_description: UnifiAccessEventEntityDescription + + def __init__( + self, + coordinator: UnifiAccessCoordinator, + door_id: str, + description: UnifiAccessEventEntityDescription, + ) -> None: + """Initialize the event entity.""" + door = coordinator.data.doors[door_id] + super().__init__(coordinator, door, description.key) + self.entity_description = description + + async def async_added_to_hass(self) -> None: + """Subscribe to door events when added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.async_subscribe_door_events(self._async_handle_event) + ) + + @callback + def _async_handle_event(self, event: DoorEvent) -> None: + """Handle incoming event from coordinator.""" + if ( + event.door_id != self._door_id + or event.category != self.entity_description.category + or event.event_type not in self.event_types + ): + return + self._trigger_event(event.event_type, event.event_data) + self.async_write_ha_state() diff --git a/homeassistant/components/unifi_access/icons.json b/homeassistant/components/unifi_access/icons.json new file mode 100644 index 00000000000000..3aa5bb97d86a68 --- /dev/null +++ b/homeassistant/components/unifi_access/icons.json @@ -0,0 +1,22 @@ +{ + "entity": { + "button": { + "unlock": { + "default": "mdi:lock-open" + } + }, + "event": { + "access": { + "default": "mdi:door" + } + }, + "switch": { + "evacuation": { + "default": "mdi:exit-run" + }, + "lockdown": { + "default": "mdi:lock-alert" + } + } + } +} diff --git a/homeassistant/components/unifi_access/manifest.json b/homeassistant/components/unifi_access/manifest.json new file mode 100644 index 00000000000000..d04b99962ff57d --- /dev/null +++ b/homeassistant/components/unifi_access/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "unifi_access", + "name": "UniFi Access", + "codeowners": ["@imhotep", "@RaHehl"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/unifi_access", + "integration_type": "hub", + "iot_class": "local_push", + "loggers": ["unifi_access_api"], + "quality_scale": "bronze", + "requirements": ["py-unifi-access==1.1.0"] +} diff --git a/homeassistant/components/unifi_access/quality_scale.yaml b/homeassistant/components/unifi_access/quality_scale.yaml new file mode 100644 index 00000000000000..664cc5b4f0625d --- /dev/null +++ b/homeassistant/components/unifi_access/quality_scale.yaml @@ -0,0 +1,66 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: + status: exempt + comment: Integration uses WebSocket push updates, no polling. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not register custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: 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 + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/unifi_access/strings.json b/homeassistant/components/unifi_access/strings.json new file mode 100644 index 00000000000000..d15140648fbe2d --- /dev/null +++ b/homeassistant/components/unifi_access/strings.json @@ -0,0 +1,72 @@ +{ + "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": { + "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": "API token generated in the UniFi Access settings.", + "host": "Hostname or IP address of the UniFi Access controller.", + "verify_ssl": "Verify the SSL certificate of the controller." + } + } + } + }, + "entity": { + "button": { + "unlock": { + "name": "Unlock" + } + }, + "event": { + "access": { + "name": "Access", + "state_attributes": { + "event_type": { + "state": { + "access_denied": "Access denied", + "access_granted": "Access granted" + } + } + } + }, + "doorbell": { + "name": "Doorbell", + "state_attributes": { + "event_type": { + "state": { + "ring": "Ring" + } + } + } + } + }, + "switch": { + "evacuation": { + "name": "Evacuation" + }, + "lockdown": { + "name": "Lockdown" + } + } + }, + "exceptions": { + "emergency_failed": { + "message": "Failed to set emergency status." + }, + "unlock_failed": { + "message": "Failed to unlock the door." + } + } +} diff --git a/homeassistant/components/unifi_access/switch.py b/homeassistant/components/unifi_access/switch.py new file mode 100644 index 00000000000000..c06a1fcc8e77e0 --- /dev/null +++ b/homeassistant/components/unifi_access/switch.py @@ -0,0 +1,110 @@ +"""Switch platform for the UniFi Access integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from unifi_access_api import EmergencyStatus, UnifiAccessError + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator, UnifiAccessData +from .entity import UnifiAccessHubEntity + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class UnifiAccessSwitchEntityDescription(SwitchEntityDescription): + """Describes a UniFi Access switch entity.""" + + value_fn: Callable[[EmergencyStatus], bool] + set_fn: Callable[[EmergencyStatus, bool], EmergencyStatus] + + +SWITCH_DESCRIPTIONS: tuple[UnifiAccessSwitchEntityDescription, ...] = ( + UnifiAccessSwitchEntityDescription( + key="evacuation", + translation_key="evacuation", + value_fn=lambda s: s.evacuation, + set_fn=lambda s, v: EmergencyStatus(evacuation=v, lockdown=s.lockdown), + ), + UnifiAccessSwitchEntityDescription( + key="lockdown", + translation_key="lockdown", + value_fn=lambda s: s.lockdown, + set_fn=lambda s, v: EmergencyStatus(evacuation=s.evacuation, lockdown=v), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: UnifiAccessConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up UniFi Access switch entities.""" + coordinator = entry.runtime_data + async_add_entities( + UnifiAccessEmergencySwitch(coordinator, description) + for description in SWITCH_DESCRIPTIONS + ) + + +class UnifiAccessEmergencySwitch(UnifiAccessHubEntity, SwitchEntity): + """Representation of a UniFi Access emergency switch.""" + + entity_description: UnifiAccessSwitchEntityDescription + + def __init__( + self, + coordinator: UnifiAccessCoordinator, + description: UnifiAccessSwitchEntityDescription, + ) -> None: + """Initialize the switch entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{description.key}" + self.entity_description = description + + @property + def is_on(self) -> bool: + """Return True if the switch is on.""" + return self.entity_description.value_fn(self.coordinator.data.emergency) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self._async_set_emergency(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self._async_set_emergency(False) + + async def _async_set_emergency(self, value: bool) -> None: + """Set emergency status.""" + new_status = self.entity_description.set_fn( + self.coordinator.data.emergency, value + ) + try: + await self.coordinator.client.set_emergency_status(new_status) + except UnifiAccessError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="emergency_failed", + ) from err + # Optimistically update state; the WebSocket confirmation via + # access.data.setting.update typically arrives ~200ms later. + # Guard against flipping coordinator.last_update_success back to True + # while the WebSocket is disconnected and all entities are unavailable. + if self.coordinator.last_update_success: + self.coordinator.async_set_updated_data( + UnifiAccessData( + doors=self.coordinator.data.doors, + emergency=new_status, + ) + ) diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index c312ceda547e7b..9e359de481a084 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -161,6 +161,9 @@ async def _async_setup_entry( await async_migrate_data(hass, entry, data_service.api, bootstrap) data_service.async_setup() + # Load PTZ patrol data before loading platforms + await data_service.async_load_ptz_patrols() + # Create the NVR device before loading platforms # This ensures via_device references work for all device entities nvr = bootstrap.nvr diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 1c03febe74bf2d..1cb56b7311f5f1 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections import defaultdict from collections.abc import Callable, Generator, Iterable from datetime import datetime, timedelta @@ -17,6 +18,7 @@ EventType, ModelType, ProtectAdoptableDeviceModel, + PTZPatrol, WSSubscriptionMessage, ) from uiprotect.exceptions import ClientError, NotAuthorized @@ -89,6 +91,8 @@ def __init__( self.adopt_signal = _async_dispatch_id(entry, DISPATCH_ADOPT) self.add_signal = _async_dispatch_id(entry, DISPATCH_ADD) self.channels_signal = _async_dispatch_id(entry, DISPATCH_CHANNELS) + # PTZ patrol cache: camera_id -> list of patrols + self.ptz_patrols: dict[str, list[PTZPatrol]] = {} @property def disable_stream(self) -> bool: @@ -126,6 +130,27 @@ def get_cameras(self, ignore_unadopted: bool = True) -> Generator[Camera]: Generator[Camera], self.get_by_types({ModelType.CAMERA}, ignore_unadopted) ) + async def async_load_ptz_patrols(self) -> None: + """Load PTZ patrols for all PTZ cameras.""" + await asyncio.gather( + *( + self.async_load_ptz_patrols_for_camera(camera) + for camera in self.get_cameras() + ) + ) + + async def async_load_ptz_patrols_for_camera(self, camera: Camera) -> None: + """Load PTZ patrols for a specific camera.""" + if camera.feature_flags.is_ptz: + try: + self.ptz_patrols[camera.id] = await camera.get_ptz_patrols() + except ClientError: + _LOGGER.debug( + "Failed to load PTZ patrols for camera %s", + camera.display_name, + ) + self.ptz_patrols[camera.id] = [] + @callback def async_setup(self) -> None: """Subscribe and do the refresh.""" @@ -208,11 +233,22 @@ def async_add_pending_camera_id(self, camera_id: str) -> None: def _async_add_device(self, device: ProtectAdoptableDeviceModel) -> None: if device.is_adopted_by_us: _LOGGER.debug("Device adopted: %s", device.id) - async_dispatcher_send(self._hass, self.adopt_signal, device) + if isinstance(device, Camera) and device.feature_flags.is_ptz: + self._hass.async_create_task( + self._async_adopt_ptz_camera(device), + name="unifiprotect_adopt_ptz_camera", + ) + else: + async_dispatcher_send(self._hass, self.adopt_signal, device) else: _LOGGER.debug("New device detected: %s", device.id) async_dispatcher_send(self._hass, self.add_signal, device) + async def _async_adopt_ptz_camera(self, camera: Camera) -> None: + """Load PTZ patrol data and dispatch adopt signal for a PTZ camera.""" + await self.async_load_ptz_patrols_for_camera(camera) + async_dispatcher_send(self._hass, self.adopt_signal, camera) + @callback def _async_remove_device(self, device: ProtectAdoptableDeviceModel) -> None: registry = dr.async_get(self._hass) diff --git a/homeassistant/components/unifiprotect/icons.json b/homeassistant/components/unifiprotect/icons.json index 9fccfcf97ac072..f66a963da4e39b 100644 --- a/homeassistant/components/unifiprotect/icons.json +++ b/homeassistant/components/unifiprotect/icons.json @@ -246,6 +246,9 @@ "paired_camera": { "default": "mdi:cctv" }, + "ptz_patrol": { + "default": "mdi:rotate-360" + }, "recording_mode": { "default": "mdi:video-outline" } @@ -439,6 +442,9 @@ "get_user_keyring_info": { "service": "mdi:key-chain" }, + "ptz_goto_preset": { + "service": "mdi:camera-marker" + }, "remove_doorbell_text": { "service": "mdi:message-minus" }, diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 17ae417eb55d30..d921b4127d2a6a 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["uiprotect==10.1.0", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==10.2.2", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index bcfd67ca215e7e..24a2791c88bffb 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -21,6 +21,7 @@ ModelType, MountType, ProtectAdoptableDeviceModel, + PTZPatrol, RecordingMode, Sensor, Viewer, @@ -98,6 +99,9 @@ {"id": LightModeType.MANUAL.value, "name": LIGHT_MODE_OFF}, ] +PTZ_PATROL_STOP = "stop" +_KEY_PTZ_PATROL = "ptz_patrol" + DEVICE_RECORDING_MODES = [ {"id": mode.value, "name": mode.value} for mode in list(RecordingMode) ] @@ -185,10 +189,29 @@ async def _set_doorbell_message(obj: Camera, message: str) -> None: async def _set_liveview(obj: Viewer, liveview_id: str) -> None: + """Set the liveview for a viewer.""" liveview = obj.api.bootstrap.liveviews[liveview_id] await obj.set_liveview(liveview) +async def _set_ptz_patrol(obj: Camera, patrol_slot: str) -> None: + """Start or stop PTZ patrol.""" + if patrol_slot == PTZ_PATROL_STOP: + await obj.ptz_patrol_stop_public() + else: + slot = int(patrol_slot) + await obj.ptz_patrol_start_public(slot=slot) + + +PTZ_PATROL_DESCRIPTION = ProtectSelectEntityDescription[Camera]( + key=_KEY_PTZ_PATROL, + translation_key="ptz_patrol", + entity_category=EntityCategory.CONFIG, + ufp_required_field="feature_flags.is_ptz", + ufp_set_method_fn=_set_ptz_patrol, + ufp_perm=PermRequired.WRITE, +) + CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ProtectSelectEntityDescription( key="recording_mode", @@ -330,7 +353,7 @@ async def async_setup_entry( @callback def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: - async_add_entities( + entities = list( async_all_device_entities( data, ProtectSelects, @@ -338,14 +361,26 @@ def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: ufp_device=device, ) ) + if isinstance(device, Camera) and device.feature_flags.is_ptz: + patrols = data.ptz_patrols.get(device.id, []) + entities.append(ProtectPTZPatrolSelect(data, device, patrols)) + async_add_entities(entities) data.async_subscribe_adopt(_add_new_device) - async_add_entities( + + entities = list( async_all_device_entities( data, ProtectSelects, model_descriptions=_MODEL_DESCRIPTIONS ) ) + for camera in data.api.bootstrap.cameras.values(): + if camera.feature_flags.is_ptz and camera.is_adopted_by_us: + patrols = data.ptz_patrols.get(camera.id, []) + entities.append(ProtectPTZPatrolSelect(data, camera, patrols)) + + async_add_entities(entities) + class ProtectSelects(ProtectDeviceEntity, SelectEntity): """A UniFi Protect Select Entity.""" @@ -411,3 +446,57 @@ async def async_select_option(self, option: str) -> None: if self.entity_description.ufp_enum_type is not None: unifi_value = self.entity_description.ufp_enum_type(unifi_value) await self.entity_description.ufp_set(self.device, unifi_value) + + +class ProtectPTZPatrolSelect(ProtectDeviceEntity, SelectEntity): + """A UniFi Protect PTZ Patrol Select Entity.""" + + device: Camera + _attr_current_option: str | None = None + _state_attrs = ("_attr_available", "_attr_options", "_attr_current_option") + + def __init__( + self, + data: ProtectData, + device: Camera, + patrols: list[PTZPatrol], + ) -> None: + """Initialize the PTZ patrol select entity.""" + # Build options from cached patrols + self._hass_to_unifi_options: dict[str, str] = {PTZ_PATROL_STOP: PTZ_PATROL_STOP} + self._hass_to_unifi_options.update( + {patrol.name: str(patrol.slot) for patrol in patrols} + ) + self._unifi_to_hass_options = { + v: k for k, v in self._hass_to_unifi_options.items() + } + self._attr_options = list(self._hass_to_unifi_options) + + super().__init__(data, device, PTZ_PATROL_DESCRIPTION) + # Set initial state based on active patrol + self._update_patrol_state() + + def _update_patrol_state(self) -> None: + """Update the patrol state based on active_patrol_slot.""" + if self.device.active_patrol_slot is not None: + # A patrol is running - show which one + slot_str = str(self.device.active_patrol_slot) + self._attr_current_option = self._unifi_to_hass_options.get(slot_str) + else: + # No patrol running - show Stop + self._attr_current_option = PTZ_PATROL_STOP + + @callback + def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: + super()._async_update_device_from_protect(device) + # Update patrol state from websocket updates + self._update_patrol_state() + + @async_ufp_instance_command + async def async_select_option(self, option: str) -> None: + """Start or stop a PTZ patrol.""" + # Home Assistant validates options before calling this method, + # so we can safely assume the option is valid + unifi_value = self._hass_to_unifi_options[option] + await _set_ptz_patrol(self.device, unifi_value) + # State will be updated via websocket when active_patrol_slot changes diff --git a/homeassistant/components/unifiprotect/services.py b/homeassistant/components/unifiprotect/services.py index 9c651488d1eec0..3737bde8ffefe8 100644 --- a/homeassistant/components/unifiprotect/services.py +++ b/homeassistant/components/unifiprotect/services.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from collections.abc import Callable, Coroutine import logging from typing import Any, cast @@ -54,6 +55,9 @@ SERVICE_REMOVE_PRIVACY_ZONE = "remove_privacy_zone" SERVICE_SET_CHIME_PAIRED = "set_chime_paired_doorbells" SERVICE_GET_USER_KEYRING_INFO = "get_user_keyring_info" +SERVICE_PTZ_GOTO_PRESET = "ptz_goto_preset" + +ATTR_PRESET = "preset" ALL_GLOBAL_SERVICES = [ SERVICE_ADD_DOORBELL_TEXT, @@ -61,6 +65,7 @@ SERVICE_SET_CHIME_PAIRED, SERVICE_REMOVE_PRIVACY_ZONE, SERVICE_GET_USER_KEYRING_INFO, + SERVICE_PTZ_GOTO_PRESET, ] DOORBELL_TEXT_SCHEMA = vol.Schema( @@ -90,6 +95,13 @@ }, ) +PTZ_GOTO_PRESET_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_PRESET): cv.string, + }, +) + @callback def _async_get_ufp_instance(hass: HomeAssistant, device_id: str) -> ProtectApiClient: @@ -245,6 +257,59 @@ async def set_chime_paired_doorbells(call: ServiceCall) -> None: await chime.save_device(data_before_changed) +@callback +def _async_get_ptz_camera(call: ServiceCall) -> Camera: + """Get a PTZ camera from a service call, validating PTZ support.""" + camera = _async_get_ufp_camera(call) + if not camera.feature_flags.is_ptz: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_ptz_camera", + translation_placeholders={"camera_name": camera.display_name}, + ) + return camera + + +async def _async_ptz_command( + func: Callable[..., Coroutine[Any, Any, Any]], **kwargs: Any +) -> Any: + """Execute a PTZ command with error handling.""" + try: + return await func(**kwargs) + except (ClientError, ValidationError) as err: + _LOGGER.debug("Error calling UniFi Protect PTZ command: %s", err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_error", + ) from err + + +async def ptz_goto_preset(call: ServiceCall) -> None: + """Move a PTZ camera to a preset position.""" + camera = _async_get_ptz_camera(call) + preset_name: str = call.data[ATTR_PRESET] + + if preset_name.lower() == "home": + await _async_ptz_command(camera.ptz_goto_preset_public, slot=-1) + return + + presets = await _async_ptz_command(camera.get_ptz_presets) + + for preset in presets: + if preset.name == preset_name: + await _async_ptz_command(camera.ptz_goto_preset_public, slot=preset.slot) + return + + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="ptz_preset_not_found", + translation_placeholders={ + "preset_name": preset_name, + "camera_name": camera.display_name, + }, + ) + + async def get_user_keyring_info(call: ServiceCall) -> ServiceResponse: """Get the user keyring info.""" camera = _async_get_ufp_camera(call) @@ -316,6 +381,12 @@ async def get_user_keyring_info(call: ServiceCall) -> ServiceResponse: GET_USER_KEYRING_INFO_SCHEMA, SupportsResponse.ONLY, ), + ( + SERVICE_PTZ_GOTO_PRESET, + ptz_goto_preset, + PTZ_GOTO_PRESET_SCHEMA, + SupportsResponse.NONE, + ), ] diff --git a/homeassistant/components/unifiprotect/services.yaml b/homeassistant/components/unifiprotect/services.yaml index 57d32e24993e31..d9d088e02f06ef 100644 --- a/homeassistant/components/unifiprotect/services.yaml +++ b/homeassistant/components/unifiprotect/services.yaml @@ -58,3 +58,17 @@ get_user_keyring_info: selector: device: integration: unifiprotect + +ptz_goto_preset: + fields: + device_id: + required: true + selector: + device: + integration: unifiprotect + entity: + domain: camera + preset: + required: true + selector: + text: diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 0d9812abcd3943..69ac175ae39aa1 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -406,6 +406,12 @@ "paired_camera": { "name": "Paired camera" }, + "ptz_patrol": { + "name": "PTZ patrol", + "state": { + "stop": "[%key:common::state::stopped%]" + } + }, "recording_mode": { "name": "Recording mode", "state": { @@ -668,6 +674,9 @@ "not_authorized": { "message": "Not authorized to perform this action on the UniFi Protect controller" }, + "not_ptz_camera": { + "message": "Camera {camera_name} does not support PTZ" + }, "only_music_supported": { "message": "Only music media type is supported" }, @@ -677,6 +686,9 @@ "protect_version": { "message": "Your UniFi Protect version ({current_version}) is too old. Minimum required: {min_version}" }, + "ptz_preset_not_found": { + "message": "Could not find PTZ preset with name {preset_name} on camera {camera_name}" + }, "service_error": { "message": "Error calling UniFi Protect service, check the logs for more details" }, @@ -776,6 +788,20 @@ }, "name": "Get user keyring info" }, + "ptz_goto_preset": { + "description": "Moves a PTZ camera to a saved preset position.", + "fields": { + "device_id": { + "description": "The PTZ camera to move.", + "name": "[%key:component::camera::title%]" + }, + "preset": { + "description": "The name of the preset position to move to. Use 'Home' for the home position.", + "name": "Preset" + } + }, + "name": "PTZ go to preset" + }, "remove_doorbell_text": { "description": "Removes an existing custom message for doorbells.", "fields": { diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index 332d52498d160b..0f9df0c10f330a 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -533,7 +533,7 @@ def supported_features(self) -> MediaPlayerEntityFeature: return flags @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return device specific state attributes.""" active_child = self._child_state return {ATTR_ACTIVE_CHILD: active_child.entity_id} if active_child else {} diff --git a/homeassistant/components/upb/entity.py b/homeassistant/components/upb/entity.py index 8a9afa453b1d2a..72d658c64ce400 100644 --- a/homeassistant/components/upb/entity.py +++ b/homeassistant/components/upb/entity.py @@ -1,5 +1,7 @@ """Support the UPB PIM.""" +from typing import Any + from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -25,7 +27,7 @@ def unique_id(self): return self._unique_id @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the default attributes of the element.""" return self._element.as_dict() diff --git a/homeassistant/components/upcloud/manifest.json b/homeassistant/components/upcloud/manifest.json index ab79d3f5c1a17e..3f953e57936e61 100644 --- a/homeassistant/components/upcloud/manifest.json +++ b/homeassistant/components/upcloud/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@scop"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/upcloud", + "integration_type": "service", "iot_class": "cloud_polling", "requirements": ["upcloud-api==2.9.0"] } diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index 47cc5aa369b2b3..2d9f13f02ada34 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -290,9 +290,7 @@ def entity_picture(self) -> str | None: Update entities return the brand icon based on the integration domain by default. """ - return ( - f"https://brands.home-assistant.io/_/{self.platform.platform_name}/icon.png" - ) + return f"/api/brands/integration/{self.platform.platform_name}/icon.png" @cached_property def in_progress(self) -> bool | None: diff --git a/homeassistant/components/uptime_kuma/config_flow.py b/homeassistant/components/uptime_kuma/config_flow.py index f0a27fab8917f2..19eb6240d7683c 100644 --- a/homeassistant/components/uptime_kuma/config_flow.py +++ b/homeassistant/components/uptime_kuma/config_flow.py @@ -10,6 +10,7 @@ UptimeKuma, UptimeKumaAuthenticationException, UptimeKumaException, + UptimeKumaParseException, ) import voluptuous as vol from yarl import URL @@ -60,6 +61,8 @@ async def validate_connection( await uptime_kuma.metrics() except UptimeKumaAuthenticationException: errors["base"] = "invalid_auth" + except UptimeKumaParseException: + errors["base"] = "invalid_data" except UptimeKumaException: errors["base"] = "cannot_connect" except Exception: diff --git a/homeassistant/components/uptime_kuma/const.py b/homeassistant/components/uptime_kuma/const.py index 2bd4b1f91659cf..990f8899e6da7a 100644 --- a/homeassistant/components/uptime_kuma/const.py +++ b/homeassistant/components/uptime_kuma/const.py @@ -24,3 +24,5 @@ MonitorType.TAILSCALE_PING, MonitorType.DNS, } + +LOCAL_INSTANCE = ("127.0.0.1", "localhost", "a0d7b954-uptime-kuma") diff --git a/homeassistant/components/uptime_kuma/coordinator.py b/homeassistant/components/uptime_kuma/coordinator.py index 98f452bf7a8cf5..93d3243ecf0c2d 100644 --- a/homeassistant/components/uptime_kuma/coordinator.py +++ b/homeassistant/components/uptime_kuma/coordinator.py @@ -11,6 +11,7 @@ UptimeKumaAuthenticationException, UptimeKumaException, UptimeKumaMonitor, + UptimeKumaParseException, UptimeKumaVersion, ) from pythonkuma.update import LatestRelease, UpdateChecker @@ -68,7 +69,14 @@ async def _async_update_data(self) -> dict[str | int, UptimeKumaMonitor]: translation_domain=DOMAIN, translation_key="auth_failed_exception", ) from e + except UptimeKumaParseException as e: + _LOGGER.debug("Full exception", exc_info=True) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="parsing_failed_exception", + ) from e except UptimeKumaException as e: + _LOGGER.debug("Full exception", exc_info=True) raise UpdateFailed( translation_domain=DOMAIN, translation_key="request_failed_exception", diff --git a/homeassistant/components/uptime_kuma/icons.json b/homeassistant/components/uptime_kuma/icons.json index 9dd3a34f9d7785..d4eda8196b9690 100644 --- a/homeassistant/components/uptime_kuma/icons.json +++ b/homeassistant/components/uptime_kuma/icons.json @@ -30,6 +30,9 @@ "pending": "mdi:lan-pending" } }, + "tags": { + "default": "mdi:tag" + }, "type": { "default": "mdi:protocol" }, diff --git a/homeassistant/components/uptime_kuma/manifest.json b/homeassistant/components/uptime_kuma/manifest.json index dc323e7b088a8d..b234ca2ab683b7 100644 --- a/homeassistant/components/uptime_kuma/manifest.json +++ b/homeassistant/components/uptime_kuma/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["pythonkuma"], "quality_scale": "platinum", - "requirements": ["pythonkuma==0.4.1"] + "requirements": ["pythonkuma==0.5.0"] } diff --git a/homeassistant/components/uptime_kuma/sensor.py b/homeassistant/components/uptime_kuma/sensor.py index 6c183cde872a7b..ff2ba2fed17b6b 100644 --- a/homeassistant/components/uptime_kuma/sensor.py +++ b/homeassistant/components/uptime_kuma/sensor.py @@ -2,17 +2,20 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass from enum import StrEnum +from typing import Any from pythonkuma import MonitorType, UptimeKumaMonitor from pythonkuma.models import MonitorStatus +from yarl import URL from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.const import CONF_URL, PERCENTAGE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant, callback @@ -21,7 +24,7 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, HAS_CERT, HAS_HOST, HAS_PORT, HAS_URL +from .const import DOMAIN, HAS_CERT, HAS_HOST, HAS_PORT, HAS_URL, LOCAL_INSTANCE from .coordinator import UptimeKumaConfigEntry, UptimeKumaDataUpdateCoordinator PARALLEL_UPDATES = 0 @@ -43,6 +46,7 @@ class UptimeKumaSensor(StrEnum): AVG_RESPONSE_TIME_1D = "avg_response_time_1d" AVG_RESPONSE_TIME_30D = "avg_response_time_30d" AVG_RESPONSE_TIME_365D = "avg_response_time_365d" + TAGS = "tags" @dataclass(kw_only=True, frozen=True) @@ -51,6 +55,7 @@ class UptimeKumaSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[UptimeKumaMonitor], StateType] create_entity: Callable[[MonitorType], bool] + attributes_fn: Callable[[UptimeKumaMonitor], Mapping[str, Any]] | None = None SENSOR_DESCRIPTIONS: tuple[UptimeKumaSensorEntityDescription, ...] = ( @@ -71,6 +76,7 @@ class UptimeKumaSensorEntityDescription(SensorEntityDescription): lambda m: m.monitor_response_time if m.monitor_response_time > -1 else None ), create_entity=lambda _: True, + state_class=SensorStateClass.MEASUREMENT, ), UptimeKumaSensorEntityDescription( key=UptimeKumaSensor.STATUS, @@ -110,13 +116,6 @@ class UptimeKumaSensorEntityDescription(SensorEntityDescription): value_fn=lambda m: m.monitor_port, create_entity=lambda t: t in HAS_PORT, ), - UptimeKumaSensorEntityDescription( - key=UptimeKumaSensor.PORT, - translation_key=UptimeKumaSensor.PORT, - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda m: m.monitor_port, - create_entity=lambda t: t in HAS_PORT, - ), UptimeKumaSensorEntityDescription( key=UptimeKumaSensor.UPTIME_RATIO_1D, translation_key=UptimeKumaSensor.UPTIME_RATIO_1D, @@ -128,6 +127,7 @@ class UptimeKumaSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=PERCENTAGE, suggested_display_precision=2, create_entity=lambda t: True, + state_class=SensorStateClass.MEASUREMENT, ), UptimeKumaSensorEntityDescription( key=UptimeKumaSensor.UPTIME_RATIO_30D, @@ -140,6 +140,7 @@ class UptimeKumaSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=PERCENTAGE, suggested_display_precision=2, create_entity=lambda t: True, + state_class=SensorStateClass.MEASUREMENT, ), UptimeKumaSensorEntityDescription( key=UptimeKumaSensor.UPTIME_RATIO_365D, @@ -152,6 +153,7 @@ class UptimeKumaSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=PERCENTAGE, suggested_display_precision=2, create_entity=lambda t: True, + state_class=SensorStateClass.MEASUREMENT, ), UptimeKumaSensorEntityDescription( key=UptimeKumaSensor.AVG_RESPONSE_TIME_1D, @@ -161,6 +163,7 @@ class UptimeKumaSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfTime.SECONDS, suggested_unit_of_measurement=UnitOfTime.MILLISECONDS, create_entity=lambda t: True, + state_class=SensorStateClass.MEASUREMENT, ), UptimeKumaSensorEntityDescription( key=UptimeKumaSensor.AVG_RESPONSE_TIME_30D, @@ -170,6 +173,7 @@ class UptimeKumaSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfTime.SECONDS, suggested_unit_of_measurement=UnitOfTime.MILLISECONDS, create_entity=lambda t: True, + state_class=SensorStateClass.MEASUREMENT, ), UptimeKumaSensorEntityDescription( key=UptimeKumaSensor.AVG_RESPONSE_TIME_365D, @@ -179,6 +183,16 @@ class UptimeKumaSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfTime.SECONDS, suggested_unit_of_measurement=UnitOfTime.MILLISECONDS, create_entity=lambda t: True, + state_class=SensorStateClass.MEASUREMENT, + ), + UptimeKumaSensorEntityDescription( + key=UptimeKumaSensor.TAGS, + translation_key=UptimeKumaSensor.TAGS, + value_fn=lambda m: len(m.monitor_tags), + create_entity=lambda t: True, + entity_category=EntityCategory.DIAGNOSTIC, + attributes_fn=lambda m: {"tags": m.monitor_tags or None}, + entity_registry_enabled_default=False, ), ) @@ -233,16 +247,21 @@ def __init__( self._attr_unique_id = ( f"{coordinator.config_entry.entry_id}_{monitor!s}_{entity_description.key}" ) + + url = URL(coordinator.config_entry.data[CONF_URL]) / "dashboard" + if url.host in LOCAL_INSTANCE: + configuration_url = None + elif isinstance(monitor, int): + configuration_url = url / str(monitor) + else: + configuration_url = url + self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, name=coordinator.data[monitor].monitor_name, identifiers={(DOMAIN, f"{coordinator.config_entry.entry_id}_{monitor!s}")}, manufacturer="Uptime Kuma", - configuration_url=( - None - if "127.0.0.1" in (url := coordinator.config_entry.data[CONF_URL]) - else url - ), + configuration_url=configuration_url, sw_version=coordinator.api.version.version, ) @@ -256,3 +275,10 @@ def native_value(self) -> StateType: def available(self) -> bool: """Return True if entity is available.""" return super().available and self.monitor in self.coordinator.data + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return entity specific state attributes.""" + if (fn := self.entity_description.attributes_fn) is not None: + return fn(self.coordinator.data[self.monitor]) + return super().extra_state_attributes diff --git a/homeassistant/components/uptime_kuma/strings.json b/homeassistant/components/uptime_kuma/strings.json index e4c7f000fa41df..d6cde39254600d 100644 --- a/homeassistant/components/uptime_kuma/strings.json +++ b/homeassistant/components/uptime_kuma/strings.json @@ -8,6 +8,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_data": "Invalid data received, check the URL", "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { @@ -91,6 +92,15 @@ "up": "Up" } }, + "tags": { + "name": "Tags", + "state_attributes": { + "tags": { + "name": "[%key:component::uptime_kuma::entity::sensor::tags::name%]" + } + }, + "unit_of_measurement": "tags" + }, "type": { "name": "Monitor type", "state": { @@ -149,6 +159,9 @@ "auth_failed_exception": { "message": "Authentication with Uptime Kuma failed. Please check that your API key is correct and still valid" }, + "parsing_failed_exception": { + "message": "Invalid data received. Please verify that the Uptime Kuma URL is correct" + }, "request_failed_exception": { "message": "Connection to Uptime Kuma failed" }, diff --git a/homeassistant/components/uptime_kuma/update.py b/homeassistant/components/uptime_kuma/update.py index 6fe4e477f0bf06..0e9f3846415196 100644 --- a/homeassistant/components/uptime_kuma/update.py +++ b/homeassistant/components/uptime_kuma/update.py @@ -4,19 +4,21 @@ from enum import StrEnum +from yarl import URL + from homeassistant.components.update import ( UpdateEntity, UpdateEntityDescription, UpdateEntityFeature, ) -from homeassistant.const import CONF_URL +from homeassistant.const import CONF_URL, EntityCategory 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 . import UPTIME_KUMA_KEY -from .const import DOMAIN +from .const import DOMAIN, LOCAL_INSTANCE from .coordinator import ( UptimeKumaConfigEntry, UptimeKumaDataUpdateCoordinator, @@ -53,6 +55,7 @@ class UptimeKumaUpdateEntity( entity_description = UpdateEntityDescription( key=UptimeKumaUpdate.UPDATE, translation_key=UptimeKumaUpdate.UPDATE, + entity_category=EntityCategory.DIAGNOSTIC, ) _attr_supported_features = UpdateEntityFeature.RELEASE_NOTES _attr_has_entity_name = True @@ -66,12 +69,14 @@ def __init__( super().__init__(coordinator) self.update_checker = update_coordinator + url = URL(coordinator.config_entry.data[CONF_URL]) / "dashboard" + configuration_url = None if url.host in LOCAL_INSTANCE else url self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, name=coordinator.config_entry.title, identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, manufacturer="Uptime Kuma", - configuration_url=coordinator.config_entry.data[CONF_URL], + configuration_url=configuration_url, sw_version=coordinator.api.version.version, ) self._attr_unique_id = ( diff --git a/homeassistant/components/uptimerobot/manifest.json b/homeassistant/components/uptimerobot/manifest.json index 58bb79c361da45..c7c2ea469a87c1 100644 --- a/homeassistant/components/uptimerobot/manifest.json +++ b/homeassistant/components/uptimerobot/manifest.json @@ -4,8 +4,9 @@ "codeowners": ["@ludeeus", "@chemelli74"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/uptimerobot", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyuptimerobot"], - "quality_scale": "bronze", + "quality_scale": "gold", "requirements": ["pyuptimerobot==24.0.1"] } diff --git a/homeassistant/components/uptimerobot/quality_scale.yaml b/homeassistant/components/uptimerobot/quality_scale.yaml index de85152315a265..1957ab189e3289 100644 --- a/homeassistant/components/uptimerobot/quality_scale.yaml +++ b/homeassistant/components/uptimerobot/quality_scale.yaml @@ -30,9 +30,7 @@ rules: config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done - entity-unavailable: - status: todo - comment: Change the type of the coordinator data to be a dict[str, UptimeRobotMonitor] so we can just do a dict look up instead of iterating over the whole list + entity-unavailable: done integration-owner: done log-when-unavailable: done parallel-updates: done diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 3c154e2887bee0..ec726bba460667 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -29,6 +29,7 @@ from homeassistant.helpers.service_info.usb import UsbServiceInfo as _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 @@ -42,6 +43,7 @@ ) _LOGGER = logging.getLogger(__name__) +_USB_DATA: HassKey[USBDiscovery] = HassKey(DOMAIN) PORT_EVENT_CALLBACK_TYPE = Callable[[set[USBDevice], set[USBDevice]], None] @@ -67,8 +69,7 @@ def async_register_scan_request_callback( hass: HomeAssistant, callback: CALLBACK_TYPE ) -> CALLBACK_TYPE: """Register to receive a callback when a scan should be initiated.""" - discovery: USBDiscovery = hass.data[DOMAIN] - return discovery.async_register_scan_request_callback(callback) + return hass.data[_USB_DATA].async_register_scan_request_callback(callback) @hass_callback @@ -79,8 +80,7 @@ def async_register_initial_scan_callback( If the initial scan is already done, the callback is called immediately. """ - discovery: USBDiscovery = hass.data[DOMAIN] - return discovery.async_register_initial_scan_callback(callback) + return hass.data[_USB_DATA].async_register_initial_scan_callback(callback) @hass_callback @@ -88,8 +88,7 @@ def async_register_port_event_callback( hass: HomeAssistant, callback: PORT_EVENT_CALLBACK_TYPE ) -> CALLBACK_TYPE: """Register to receive a callback when a USB device is connected or disconnected.""" - discovery: USBDiscovery = hass.data[DOMAIN] - return discovery.async_register_port_event_callback(callback) + return hass.data[_USB_DATA].async_register_port_event_callback(callback) @hass_callback @@ -97,8 +96,7 @@ def async_get_usb_matchers_for_device( hass: HomeAssistant, device: USBDevice ) -> list[USBMatcher]: """Return a list of matchers that match the given device.""" - usb_discovery: USBDiscovery = hass.data[DOMAIN] - return usb_discovery.async_get_usb_matchers_for_device(device) + return hass.data[_USB_DATA].async_get_usb_matchers_for_device(device) @overload @@ -159,7 +157,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: usb = await async_get_usb(hass) usb_discovery = USBDiscovery(hass, usb) await usb_discovery.async_setup() - hass.data[DOMAIN] = usb_discovery + hass.data[_USB_DATA] = usb_discovery websocket_api.async_register_command(hass, websocket_usb_scan) return True @@ -167,7 +165,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_request_scan(hass: HomeAssistant) -> None: """Request a USB scan.""" - usb_discovery: USBDiscovery = hass.data[DOMAIN] + usb_discovery = hass.data[_USB_DATA] if not usb_discovery.observer_active: await usb_discovery.async_request_scan() diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index faa55ced255b8c..f7e6f6e3008235 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -702,7 +702,7 @@ def state_class(self) -> SensorStateClass: ) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the sensor.""" state_attr = { ATTR_STATUS: PAUSED if self._collecting is None else COLLECTING, diff --git a/homeassistant/components/v2c/manifest.json b/homeassistant/components/v2c/manifest.json index 3a6eab0f335d20..ea9f3e3579e9d5 100644 --- a/homeassistant/components/v2c/manifest.json +++ b/homeassistant/components/v2c/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@dgomes"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/v2c", + "integration_type": "device", "iot_class": "local_polling", "requirements": ["pytrydan==0.8.0"] } diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 2e68cf3938cb23..0347e401da8da1 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -3,6 +3,8 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping +from dataclasses import dataclass from datetime import timedelta from functools import partial import logging @@ -21,7 +23,8 @@ STATE_ON, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_platform import EntityPlatform @@ -31,6 +34,7 @@ from homeassistant.loader import bind_hass from .const import DATA_COMPONENT, DOMAIN, VacuumActivity, VacuumEntityFeature +from .websocket import async_register_websocket_handlers _LOGGER = logging.getLogger(__name__) @@ -47,6 +51,7 @@ ATTR_STATUS = "status" SERVICE_CLEAN_SPOT = "clean_spot" +SERVICE_CLEAN_AREA = "clean_area" SERVICE_LOCATE = "locate" SERVICE_RETURN_TO_BASE = "return_to_base" SERVICE_SEND_COMMAND = "send_command" @@ -58,6 +63,8 @@ DEFAULT_NAME = "Vacuum cleaner robot" +ISSUE_SEGMENTS_CHANGED = "segments_changed" + _BATTERY_DEPRECATION_IGNORED_PLATFORMS = ("template",) @@ -78,6 +85,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await component.async_setup(config) + async_register_websocket_handlers(hass) + component.async_register_entity_service( SERVICE_START, None, @@ -102,6 +111,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "async_clean_spot", [VacuumEntityFeature.CLEAN_SPOT], ) + component.async_register_entity_service( + SERVICE_CLEAN_AREA, + { + vol.Required("cleaning_area_id"): vol.All(cv.ensure_list, [str]), + }, + "async_internal_clean_area", + [VacuumEntityFeature.CLEAN_AREA], + ) component.async_register_entity_service( SERVICE_LOCATE, None, @@ -173,6 +190,9 @@ class StateVacuumEntity( _attr_activity: VacuumActivity | None = None _attr_supported_features: VacuumEntityFeature = VacuumEntityFeature(0) + _segments_not_configured_issue_created: bool = False + _segments_changed_last_seen: list[dict[str, Any]] | None = None + __vacuum_legacy_battery_level: bool = False __vacuum_legacy_battery_icon: bool = False __vacuum_legacy_battery_feature: bool = False @@ -216,6 +236,11 @@ def add_to_platform_start( if self.__vacuum_legacy_battery_icon: self._report_deprecated_battery_properties("battery_icon") + @callback + def async_registry_entry_updated(self) -> None: + """Run when the entity registry entry has been updated.""" + self._async_check_segments_issues() + @callback def _report_deprecated_battery_properties(self, property: str) -> None: """Report on deprecated use of battery properties. @@ -368,6 +393,137 @@ async def async_clean_spot(self, **kwargs: Any) -> None: """ await self.hass.async_add_executor_job(partial(self.clean_spot, **kwargs)) + async def async_get_segments(self) -> list[Segment]: + """Get the segments that can be cleaned. + + Returns a list of segments containing their ids and names. + """ + raise NotImplementedError + + @final + @property + def last_seen_segments(self) -> list[Segment] | None: + """Return segments as seen by the user, when last mapping the areas. + + Returns None if no mapping has been saved yet. + This can be used by integrations to detect changes in segments reported + by the vacuum and create a repair issue. + """ + if self.registry_entry is None: + raise RuntimeError( + "Cannot access last_seen_segments, registry entry is not set for" + f" {self.entity_id}" + ) + + options: Mapping[str, Any] = self.registry_entry.options.get(DOMAIN, {}) + last_seen_segments = options.get("last_seen_segments") + + if last_seen_segments is None: + return None + + return [Segment(**segment) for segment in last_seen_segments] + + @final + async def async_internal_clean_area( + self, cleaning_area_id: list[str], **kwargs: Any + ) -> None: + """Perform an area clean. + + Calls async_clean_segments. + """ + if self.registry_entry is None: + raise RuntimeError( + "Cannot perform area clean, registry entry is not set for" + f" {self.entity_id}" + ) + + 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: + 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, + ) + return + + await self.async_clean_segments(list(segment_ids), **kwargs) + + def clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None: + """Perform an area clean.""" + raise NotImplementedError + + async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None: + """Perform an area clean.""" + await self.hass.async_add_executor_job( + partial(self.clean_segments, segment_ids, **kwargs) + ) + + @callback + def async_create_segments_issue(self) -> None: + """Create a repair issue when vacuum segments have changed. + + Integrations should call this method when the vacuum reports + different segments than what was previously mapped to areas. + + The issue is not fixable via the standard repair flow. The frontend + will handle the fix by showing the segment mapping dialog. + """ + if self.registry_entry is None: + raise RuntimeError( + "Cannot create segments issue, registry entry is not set for" + f" {self.entity_id}" + ) + + issue_id = f"{ISSUE_SEGMENTS_CHANGED}_{self.registry_entry.id}" + ir.async_create_issue( + self.hass, + DOMAIN, + issue_id, + data={ + "entry_id": self.registry_entry.id, + "entity_id": self.entity_id, + }, + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key=ISSUE_SEGMENTS_CHANGED, + translation_placeholders={ + "entity_id": self.entity_id, + }, + ) + options: Mapping[str, Any] = self.registry_entry.options.get(DOMAIN, {}) + self._segments_changed_last_seen = options.get("last_seen_segments") + + @callback + def _async_check_segments_issues(self) -> None: + """Create or delete segment-related repair issues.""" + if self.registry_entry is None: + return + + options: Mapping[str, Any] = self.registry_entry.options.get(DOMAIN, {}) + + if self._segments_changed_last_seen is not None and ( + VacuumEntityFeature.CLEAN_AREA not in self.supported_features + or options.get("last_seen_segments") != self._segments_changed_last_seen + ): + issue_id = f"{ISSUE_SEGMENTS_CHANGED}_{self.registry_entry.id}" + ir.async_delete_issue(self.hass, DOMAIN, issue_id) + self._segments_changed_last_seen = None + def locate(self, **kwargs: Any) -> None: """Locate the vacuum cleaner.""" raise NotImplementedError @@ -436,3 +592,12 @@ async def async_pause(self) -> None: This method must be run in the event loop. """ await self.hass.async_add_executor_job(self.pause) + + +@dataclass(slots=True) +class Segment: + """Represents a cleanable segment reported by a vacuum.""" + + id: str + name: str + group: str | None = None diff --git a/homeassistant/components/vacuum/const.py b/homeassistant/components/vacuum/const.py index a6e8703a1b0752..919eb1df5660ba 100644 --- a/homeassistant/components/vacuum/const.py +++ b/homeassistant/components/vacuum/const.py @@ -44,3 +44,4 @@ class VacuumEntityFeature(IntFlag): MAP = 2048 STATE = 4096 # Must be set by vacuum platforms derived from StateVacuumEntity START = 8192 + CLEAN_AREA = 16384 diff --git a/homeassistant/components/vacuum/icons.json b/homeassistant/components/vacuum/icons.json index 7cc83f647dd011..dabca1057ac244 100644 --- a/homeassistant/components/vacuum/icons.json +++ b/homeassistant/components/vacuum/icons.json @@ -22,6 +22,9 @@ } }, "services": { + "clean_area": { + "service": "mdi:target-variant" + }, "clean_spot": { "service": "mdi:target-variant" }, diff --git a/homeassistant/components/vacuum/intent.py b/homeassistant/components/vacuum/intent.py index c5edbbd0338fa8..4072b1b026b704 100644 --- a/homeassistant/components/vacuum/intent.py +++ b/homeassistant/components/vacuum/intent.py @@ -1,12 +1,25 @@ """Intents for the vacuum integration.""" +import logging + +import voluptuous as vol + from homeassistant.core import HomeAssistant -from homeassistant.helpers import intent +from homeassistant.helpers import area_registry as ar, config_validation as cv, intent -from . import DOMAIN, SERVICE_RETURN_TO_BASE, SERVICE_START, VacuumEntityFeature +from . import ( + DOMAIN, + SERVICE_CLEAN_AREA, + SERVICE_RETURN_TO_BASE, + SERVICE_START, + VacuumEntityFeature, +) + +_LOGGER = logging.getLogger(__name__) INTENT_VACUUM_START = "HassVacuumStart" INTENT_VACUUM_RETURN_TO_BASE = "HassVacuumReturnToBase" +INTENT_VACUUM_CLEAN_AREA = "HassVacuumCleanArea" async def async_setup_intents(hass: HomeAssistant) -> None: @@ -35,3 +48,156 @@ async def async_setup_intents(hass: HomeAssistant) -> None: required_features=VacuumEntityFeature.RETURN_HOME, ), ) + intent.async_register(hass, CleanAreaIntentHandler()) + + +class CleanAreaIntentHandler(intent.IntentHandler): + """Intent handler for cleaning a specific area with a vacuum. + + The area slot is used as a service parameter (cleaning_area_id), + not for entity matching. + """ + + intent_type = INTENT_VACUUM_CLEAN_AREA + platforms = {DOMAIN} + description = "Tells a vacuum to clean a specific area" + + @property + def slot_schema(self) -> dict: + """Return a slot schema.""" + return { + vol.Required("area"): cv.string, + vol.Optional("name"): cv.string, + vol.Optional("preferred_area_id"): cv.string, + vol.Optional("preferred_floor_id"): cv.string, + } + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + slots = self.async_validate_slots(intent_obj.slots) + + # Resolve the area name to an area ID + area_name = slots["area"]["value"] + area_reg = ar.async_get(hass) + matched_areas = list(intent.find_areas(area_name, area_reg)) + if not matched_areas: + raise intent.MatchFailedError( + result=intent.MatchTargetsResult( + is_match=False, + no_match_reason=intent.MatchFailedReason.INVALID_AREA, + no_match_name=area_name, + ), + constraints=intent.MatchTargetsConstraints( + area_name=area_name, + ), + ) + + # Use preferred area/floor from conversation context to disambiguate + preferred_area_id = slots.get("preferred_area_id", {}).get("value") + preferred_floor_id = slots.get("preferred_floor_id", {}).get("value") + if len(matched_areas) > 1 and preferred_area_id is not None: + filtered = [a for a in matched_areas if a.id == preferred_area_id] + if filtered: + matched_areas = filtered + if len(matched_areas) > 1 and preferred_floor_id is not None: + filtered = [a for a in matched_areas if a.floor_id == preferred_floor_id] + if filtered: + matched_areas = filtered + + # Match vacuum entity by name + name_slot = slots.get("name", {}) + entity_name: str | None = name_slot.get("value") + + match_constraints = intent.MatchTargetsConstraints( + name=entity_name, + domains={DOMAIN}, + features=VacuumEntityFeature.CLEAN_AREA, + assistant=intent_obj.assistant, + ) + + # Use the resolved cleaning area and its floor as preferences + # for entity disambiguation + target_area = matched_areas[0] + match_preferences = intent.MatchTargetsPreferences( + area_id=target_area.id, + floor_id=target_area.floor_id, + ) + + match_result = intent.async_match_targets( + hass, match_constraints, match_preferences + ) + if not match_result.is_match: + raise intent.MatchFailedError( + result=match_result, + constraints=match_constraints, + preferences=match_preferences, + ) + + # Update intent slots to include any transformations done by the schemas + intent_obj.slots = slots + + return await self._async_handle_service(intent_obj, match_result, matched_areas) + + async def _async_handle_service( + self, + intent_obj: intent.Intent, + match_result: intent.MatchTargetsResult, + matched_areas: list[ar.AreaEntry], + ) -> intent.IntentResponse: + """Call clean_area for all matched areas.""" + hass = intent_obj.hass + states = match_result.states + + entity_ids = [state.entity_id for state in states] + area_ids = [area.id for area in matched_areas] + + try: + await hass.services.async_call( + DOMAIN, + SERVICE_CLEAN_AREA, + { + "entity_id": entity_ids, + "cleaning_area_id": area_ids, + }, + context=intent_obj.context, + blocking=True, + ) + except Exception: + _LOGGER.exception( + "Failed to call %s for areas: %s with vacuums: %s", + SERVICE_CLEAN_AREA, + area_ids, + entity_ids, + ) + raise intent.IntentHandleError( + f"Failed to call {SERVICE_CLEAN_AREA} for areas: {area_ids}" + f" with vacuums: {entity_ids}" + ) from None + + success_results: list[intent.IntentResponseTarget] = [ + intent.IntentResponseTarget( + type=intent.IntentResponseTargetType.AREA, + name=area.name, + id=area.id, + ) + for area in matched_areas + ] + success_results.extend( + intent.IntentResponseTarget( + type=intent.IntentResponseTargetType.ENTITY, + name=state.name, + id=state.entity_id, + ) + for state in states + ) + + response = intent_obj.create_response() + + response.async_set_results(success_results) + + # Update all states + states = [hass.states.get(state.entity_id) or state for state in states] + response.async_set_states(states) + + return response diff --git a/homeassistant/components/vacuum/services.yaml b/homeassistant/components/vacuum/services.yaml index 25f3822bd35549..9764f86f556c28 100644 --- a/homeassistant/components/vacuum/services.yaml +++ b/homeassistant/components/vacuum/services.yaml @@ -69,6 +69,20 @@ clean_spot: entity: domain: vacuum +clean_area: + target: + entity: + domain: vacuum + supported_features: + - vacuum.VacuumEntityFeature.CLEAN_AREA + fields: + cleaning_area_id: + required: true + selector: + area: + multiple: true + reorder: true + send_command: target: entity: diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json index 8e980aedb54dba..07947008bafb2b 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -89,6 +89,17 @@ } } }, + "exceptions": { + "area_mapping_not_configured": { + "message": "Area mapping is not configured for `{entity_id}`. Configure the segment-to-area mapping before using this action." + } + }, + "issues": { + "segments_changed": { + "description": "", + "title": "Vacuum segments have changed for {entity_id}" + } + }, "selector": { "condition_behavior": { "options": { @@ -105,12 +116,22 @@ } }, "services": { + "clean_area": { + "description": "Tells a vacuum cleaner to clean one or more areas.", + "fields": { + "cleaning_area_id": { + "description": "Areas to clean.", + "name": "Areas" + } + }, + "name": "Clean area" + }, "clean_spot": { - "description": "Tells the vacuum cleaner to do a spot clean-up.", + "description": "Tells a vacuum cleaner to do a spot clean-up.", "name": "Clean spot" }, "locate": { - "description": "Locates the vacuum cleaner robot.", + "description": "Locates a vacuum cleaner robot.", "name": "Locate" }, "pause": { @@ -118,11 +139,11 @@ "name": "[%key:common::action::pause%]" }, "return_to_base": { - "description": "Tells the vacuum cleaner to return to its dock.", + "description": "Tells a vacuum cleaner to return to its dock.", "name": "Return to dock" }, "send_command": { - "description": "Sends a command to the vacuum cleaner.", + "description": "Sends a command to a vacuum cleaner.", "fields": { "command": { "description": "Command to execute. The commands are integration-specific.", @@ -136,7 +157,7 @@ "name": "Send command" }, "set_fan_speed": { - "description": "Sets the fan speed of the vacuum cleaner.", + "description": "Sets the fan speed of a vacuum cleaner.", "fields": { "fan_speed": { "description": "Fan speed. The value depends on the integration. Some integrations have speed steps, like 'medium'. Some use a percentage, between 0 and 100.", diff --git a/homeassistant/components/vacuum/websocket.py b/homeassistant/components/vacuum/websocket.py new file mode 100644 index 00000000000000..7be4187bc13a49 --- /dev/null +++ b/homeassistant/components/vacuum/websocket.py @@ -0,0 +1,51 @@ +"""Websocket commands for the Vacuum integration.""" + +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.components.websocket_api import ERR_NOT_FOUND, ERR_NOT_SUPPORTED +from homeassistant.core import HomeAssistant, callback +import homeassistant.helpers.config_validation as cv + +from .const import DATA_COMPONENT, VacuumEntityFeature + + +@callback +def async_register_websocket_handlers(hass: HomeAssistant) -> None: + """Register websocket commands.""" + websocket_api.async_register_command(hass, handle_get_segments) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "vacuum/get_segments", + vol.Required("entity_id"): cv.strict_entity_id, + } +) +@websocket_api.async_response +async def handle_get_segments( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get segments for a vacuum.""" + entity_id = msg["entity_id"] + entity = hass.data[DATA_COMPONENT].get_entity(entity_id) + if entity is None: + connection.send_error(msg["id"], ERR_NOT_FOUND, f"Entity {entity_id} not found") + return + + if VacuumEntityFeature.CLEAN_AREA not in entity.supported_features: + connection.send_error( + msg["id"], ERR_NOT_SUPPORTED, f"Entity {entity_id} not supported" + ) + return + + segments = await entity.async_get_segments() + + connection.send_result(msg["id"], {"segments": segments}) diff --git a/homeassistant/components/vallox/manifest.json b/homeassistant/components/vallox/manifest.json index 9cb3c73982567e..843df07b358db0 100644 --- a/homeassistant/components/vallox/manifest.json +++ b/homeassistant/components/vallox/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@andre-richter", "@slovdahl", "@viiru-", "@yozik04"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vallox", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["vallox_websocket_api"], "requirements": ["vallox-websocket-api==6.0.0"] diff --git a/homeassistant/components/vasttrafik/sensor.py b/homeassistant/components/vasttrafik/sensor.py index be2559d4863947..7059eb2f438a57 100644 --- a/homeassistant/components/vasttrafik/sensor.py +++ b/homeassistant/components/vasttrafik/sensor.py @@ -93,14 +93,12 @@ class VasttrafikDepartureSensor(SensorEntity): def __init__(self, planner, name, departure, heading, lines, delay): """Initialize the sensor.""" self._planner = planner - self._name = name or departure + self._attr_name = name or departure self._departure = self.get_station_id(departure) self._heading = self.get_station_id(heading) if heading else None self._lines = lines or None self._delay = timedelta(minutes=delay) self._departureboard = None - self._state = None - self._attributes = None def get_station_id(self, location): """Get the station ID.""" @@ -111,21 +109,6 @@ def get_station_id(self, location): station_info = {"station_name": location, "station_id": station_id} return station_info - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._attributes - - @property - def native_value(self): - """Return the next departure time.""" - return self._state - @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self) -> None: """Get the departure board.""" @@ -145,8 +128,8 @@ def update(self) -> None: self._departure["station_name"], self._heading["station_name"] if self._heading else "ANY", ) - self._state = None - self._attributes = {} + self._attr_native_value = None + self._attr_extra_state_attributes = {} else: for departure in self._departureboard: service_journey = departure.get("serviceJourney", {}) @@ -157,13 +140,15 @@ def update(self) -> None: if not self._lines or line.get("shortName") in self._lines: if "estimatedOtherwisePlannedTime" in departure: try: - self._state = datetime.fromisoformat( + self._attr_native_value = datetime.fromisoformat( departure["estimatedOtherwisePlannedTime"] ).strftime("%H:%M") except ValueError: - self._state = departure["estimatedOtherwisePlannedTime"] + self._attr_native_value = departure[ + "estimatedOtherwisePlannedTime" + ] else: - self._state = None + self._attr_native_value = None stop_point = departure.get("stopPoint", {}) @@ -181,5 +166,7 @@ def update(self) -> None: ATTR_DELAY: self._delay.seconds // 60 % 60, } - self._attributes = {k: v for k, v in params.items() if v} + self._attr_extra_state_attributes = { + k: v for k, v in params.items() if v + } break diff --git a/homeassistant/components/vegehub/manifest.json b/homeassistant/components/vegehub/manifest.json index f343d66c7381c0..80d01f21af7e19 100644 --- a/homeassistant/components/vegehub/manifest.json +++ b/homeassistant/components/vegehub/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["http", "webhook"], "documentation": "https://www.home-assistant.io/integrations/vegehub", + "integration_type": "hub", "iot_class": "local_push", "quality_scale": "bronze", "requirements": ["vegehub==0.1.26"], diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index e89ef15f2b6160..237323dd481e50 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.1.4"], + "requirements": ["velbus-aio==2026.2.0"], "usb": [ { "pid": "0B1B", diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py index 3c7cec96e4c653..3d672a574d6a73 100644 --- a/homeassistant/components/velux/__init__.py +++ b/homeassistant/components/velux/__init__.py @@ -11,7 +11,7 @@ CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import Event, HomeAssistant, ServiceCall from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, @@ -127,7 +127,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> boo connections=connections, ) - async def on_hass_stop(event): + async def on_hass_stop(_: Event) -> None: """Close connection when hass stops.""" LOGGER.debug("Velux interface terminated") await pyvlx.disconnect() diff --git a/homeassistant/components/velux/binary_sensor.py b/homeassistant/components/velux/binary_sensor.py index 22bec674b642b1..1b87633c9cd4fc 100644 --- a/homeassistant/components/velux/binary_sensor.py +++ b/homeassistant/components/velux/binary_sensor.py @@ -4,8 +4,7 @@ from datetime import timedelta -from pyvlx.exception import PyVLXException -from pyvlx.opening_device import OpeningDevice, Window +from pyvlx import OpeningDevice, Position, PyVLXException, Window from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -55,7 +54,7 @@ def __init__(self, node: OpeningDevice, config_entry_id: str) -> None: async def async_update(self) -> None: """Fetch the latest state from the device.""" try: - limitation = await self.node.get_limitation() + limitation: Position = await self.node.get_limitation_min() except (OSError, PyVLXException) as err: if not self._unavailable_logged: LOGGER.warning( @@ -78,4 +77,4 @@ async def async_update(self) -> None: # So far we've seen 89, 91, 93 (most cases) or 100 (Velux GPU). It probably makes sense to # assume that any large enough limitation (we use >=89) means rain is detected. # Documentation on this is non-existent AFAIK. - self._attr_is_on = limitation.min_value >= 89 + self._attr_is_on = limitation.position_percent >= 89 diff --git a/homeassistant/components/velux/const.py b/homeassistant/components/velux/const.py index ad326569e894af..9e008a59a59bb5 100644 --- a/homeassistant/components/velux/const.py +++ b/homeassistant/components/velux/const.py @@ -10,6 +10,7 @@ Platform.BUTTON, Platform.COVER, Platform.LIGHT, + Platform.NUMBER, Platform.SCENE, Platform.SWITCH, ] diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index e56fc2e54d2def..334dab34cea739 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -2,11 +2,13 @@ from __future__ import annotations +from enum import StrEnum from typing import Any -from pyvlx import ( +from pyvlx.opening_device import ( Awning, Blind, + DualRollerShutter, GarageDoor, Gate, OpeningDevice, @@ -43,6 +45,23 @@ async def async_setup_entry( for node in pyvlx.nodes: if isinstance(node, Blind): entities.append(VeluxBlind(node, config_entry.entry_id)) + elif isinstance(node, DualRollerShutter): + # add three entities, one for each part and the "dual" control + entities.append( + VeluxDualRollerShutter( + node, config_entry.entry_id, VeluxDualRollerPart.DUAL + ) + ) + entities.append( + VeluxDualRollerShutter( + node, config_entry.entry_id, VeluxDualRollerPart.UPPER + ) + ) + entities.append( + VeluxDualRollerShutter( + node, config_entry.entry_id, VeluxDualRollerPart.LOWER + ) + ) elif isinstance(node, OpeningDevice): entities.append(VeluxCover(node, config_entry.entry_id)) @@ -54,9 +73,6 @@ class VeluxCover(VeluxEntity, CoverEntity): node: OpeningDevice - # Do not name the "main" feature of the device (position control) - _attr_name = None - # Features common to all covers _attr_supported_features = ( CoverEntityFeature.OPEN @@ -125,6 +141,72 @@ async def async_stop_cover(self, **kwargs: Any) -> None: await self.node.stop(wait_for_completion=False) +class VeluxDualRollerPart(StrEnum): + """Enum for the parts of a dual roller shutter.""" + + UPPER = "upper" + LOWER = "lower" + DUAL = "dual" + + +class VeluxDualRollerShutter(VeluxCover): + """Representation of a Velux dual roller shutter cover.""" + + node: DualRollerShutter + _attr_device_class = CoverDeviceClass.SHUTTER + + def __init__( + self, node: DualRollerShutter, config_entry_id: str, part: VeluxDualRollerPart + ) -> None: + """Initialize VeluxDualRollerShutter.""" + super().__init__(node, config_entry_id) + if part == VeluxDualRollerPart.DUAL: + self._attr_name = None + else: + self._attr_unique_id = f"{self._attr_unique_id}_{part}" + self._attr_translation_key = f"dual_roller_shutter_{part}" + self.part = part + + @property + def current_cover_position(self) -> int: + """Return the current position of the cover.""" + if self.part == VeluxDualRollerPart.UPPER: + return 100 - self.node.position_upper_curtain.position_percent + if self.part == VeluxDualRollerPart.LOWER: + return 100 - self.node.position_lower_curtain.position_percent + return 100 - self.node.position.position_percent + + @property + def is_closed(self) -> bool: + """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 + + @wrap_pyvlx_call_exceptions + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + await self.node.close(curtain=self.part, wait_for_completion=False) + + @wrap_pyvlx_call_exceptions + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self.node.open(curtain=self.part, wait_for_completion=False) + + @wrap_pyvlx_call_exceptions + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + position_percent = 100 - kwargs[ATTR_POSITION] + + await self.node.set_position( + Position(position_percent=position_percent), + curtain=self.part, + wait_for_completion=False, + ) + + class VeluxBlind(VeluxCover): """Representation of a Velux blind cover.""" diff --git a/homeassistant/components/velux/diagnostics.py b/homeassistant/components/velux/diagnostics.py new file mode 100644 index 00000000000000..8422a4996a8297 --- /dev/null +++ b/homeassistant/components/velux/diagnostics.py @@ -0,0 +1,86 @@ +"""Diagnostics support for Velux.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_MAC, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import VeluxConfigEntry + +TO_REDACT = {CONF_MAC, CONF_PASSWORD} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: VeluxConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry, includes nodes, devices, and entities.""" + + pyvlx = entry.runtime_data + + nodes: list[dict[str, Any]] = [ + { + "node_id": node.node_id, + "name": node.name, + "serial_number": node.serial_number, + "type": type(node).__name__, + "device_updated_callbacks": node.device_updated_cbs, + } + for node in pyvlx.nodes + ] + + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + devices: list[dict[str, Any]] = [] + for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id): + entities: list[dict[str, Any]] = [] + for entity_entry in er.async_entries_for_device( + entity_registry, + device_id=device.id, + include_disabled_entities=True, + ): + state_dict = None + if state := hass.states.get(entity_entry.entity_id): + state_dict = dict(state.as_dict()) + state_dict.pop("context", None) + + entities.append( + { + "entity_id": entity_entry.entity_id, + "unique_id": entity_entry.unique_id, + "state": state_dict, + } + ) + + devices.append( + { + "name": device.name, + "entities": entities, + } + ) + + return { + "config_entry": async_redact_data(entry.data, TO_REDACT), + "connection": { + "connected": pyvlx.connection.connected, + "connection_count": pyvlx.connection.connection_counter, + "frame_received_cbs": pyvlx.connection.frame_received_cbs, + "connection_opened_cbs": pyvlx.connection.connection_opened_cbs, + "connection_closed_cbs": pyvlx.connection.connection_closed_cbs, + }, + "gateway": { + "state": str(pyvlx.klf200.state) if pyvlx.klf200.state else None, + "version": str(pyvlx.klf200.version) if pyvlx.klf200.version else None, + "protocol_version": ( + str(pyvlx.klf200.protocol_version) + if pyvlx.klf200.protocol_version + else None + ), + }, + "nodes": nodes, + "devices": devices, + } diff --git a/homeassistant/components/velux/entity.py b/homeassistant/components/velux/entity.py index 2743cf31694484..a43eba6cb7b3e0 100644 --- a/homeassistant/components/velux/entity.py +++ b/homeassistant/components/velux/entity.py @@ -70,7 +70,7 @@ def __init__(self, node: Node, config_entry_id: str) -> None: via_device=(DOMAIN, f"gateway_{config_entry_id}"), ) - async def after_update_callback(self, node) -> None: + async def after_update_callback(self, _: Node) -> None: """Call after device was updated.""" self._attr_available = self.node.pyvlx.get_connected() if not self._attr_available: diff --git a/homeassistant/components/velux/manifest.json b/homeassistant/components/velux/manifest.json index fbd0d94e6fa566..9ebe6ff6062f7e 100644 --- a/homeassistant/components/velux/manifest.json +++ b/homeassistant/components/velux/manifest.json @@ -1,7 +1,7 @@ { "domain": "velux", "name": "Velux", - "codeowners": ["@Julius2342", "@DeerMaximum", "@pawlizio", "@wollew"], + "codeowners": ["@Julius2342", "@pawlizio", "@wollew"], "config_flow": true, "dhcp": [ { @@ -14,5 +14,5 @@ "iot_class": "local_polling", "loggers": ["pyvlx"], "quality_scale": "silver", - "requirements": ["pyvlx==0.2.29"] + "requirements": ["pyvlx==0.2.32"] } diff --git a/homeassistant/components/velux/number.py b/homeassistant/components/velux/number.py new file mode 100644 index 00000000000000..c4f68a3eb56268 --- /dev/null +++ b/homeassistant/components/velux/number.py @@ -0,0 +1,56 @@ +"""Support for Velux exterior heating number entities.""" + +from __future__ import annotations + +from pyvlx import ExteriorHeating, Intensity + +from homeassistant.components.number import NumberEntity +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import VeluxConfigEntry +from .entity import VeluxEntity, wrap_pyvlx_call_exceptions + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: VeluxConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up number entities for the Velux platform.""" + pyvlx = config_entry.runtime_data + async_add_entities( + VeluxExteriorHeatingNumber(node, config_entry.entry_id) + for node in pyvlx.nodes + if isinstance(node, ExteriorHeating) + ) + + +class VeluxExteriorHeatingNumber(VeluxEntity, NumberEntity): + """Representation of an exterior heating intensity control.""" + + _attr_native_min_value = 0 + _attr_native_max_value = 100 + _attr_native_step = 1 + _attr_native_unit_of_measurement = PERCENTAGE + _attr_name = None + + node: ExteriorHeating + + @property + def native_value(self) -> float | None: + """Return the current heating intensity in percent.""" + return ( + self.node.intensity.intensity_percent if self.node.intensity.known else None + ) + + @wrap_pyvlx_call_exceptions + async def async_set_native_value(self, value: float) -> None: + """Set the heating intensity.""" + await self.node.set_intensity( + Intensity(intensity_percent=round(value)), + wait_for_completion=True, + ) diff --git a/homeassistant/components/velux/quality_scale.yaml b/homeassistant/components/velux/quality_scale.yaml index 1cebdb6819ad55..5c3329af14f1d5 100644 --- a/homeassistant/components/velux/quality_scale.yaml +++ b/homeassistant/components/velux/quality_scale.yaml @@ -33,7 +33,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: todo discovery: done docs-data-update: todo @@ -57,4 +57,4 @@ rules: # Platinum async-dependency: todo inject-websession: todo - strict-typing: todo + strict-typing: done diff --git a/homeassistant/components/velux/strings.json b/homeassistant/components/velux/strings.json index 13abb8a0f78cb5..a52fb0a245c9d9 100644 --- a/homeassistant/components/velux/strings.json +++ b/homeassistant/components/velux/strings.json @@ -45,6 +45,14 @@ "rain_sensor": { "name": "Rain sensor" } + }, + "cover": { + "dual_roller_shutter_lower": { + "name": "Lower shutter" + }, + "dual_roller_shutter_upper": { + "name": "Upper shutter" + } } }, "exceptions": { @@ -60,8 +68,8 @@ }, "issues": { "deprecated_reboot_service": { - "description": "The `velux.reboot_gateway` service is deprecated and will be removed in Home Assistant 2026.6.0. Please use the 'Restart' button entity instead. You can find this button in the device page for your KLF 200 Gateway or by searching for 'restart' in your entity list.", - "title": "Velux reboot service is deprecated" + "description": "The `velux.reboot_gateway` action is deprecated and will be removed in Home Assistant 2026.6.0. Please use the 'Restart' button entity instead. You can find this button in the device page for your KLF 200 Gateway or by searching for 'restart' in your entity list.", + "title": "Velux 'Reboot gateway' action deprecated" } }, "services": { diff --git a/homeassistant/components/venstar/binary_sensor.py b/homeassistant/components/venstar/binary_sensor.py index 93c233d3aeae36..18c7abdc8cc5f1 100644 --- a/homeassistant/components/venstar/binary_sensor.py +++ b/homeassistant/components/venstar/binary_sensor.py @@ -41,7 +41,7 @@ def __init__(self, coordinator, config, alert): self._attr_name = alert @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" if self._client.alerts is None: return None diff --git a/homeassistant/components/venstar/manifest.json b/homeassistant/components/venstar/manifest.json index 5991dc8fe5139c..eba5c8a6cd483b 100644 --- a/homeassistant/components/venstar/manifest.json +++ b/homeassistant/components/venstar/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@garbled1", "@jhollowe"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/venstar", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["venstarcolortouch"], "requirements": ["venstarcolortouch==0.21"] diff --git a/homeassistant/components/vera/manifest.json b/homeassistant/components/vera/manifest.json index bc4724c1638e0a..e977b2ae8b59d8 100644 --- a/homeassistant/components/vera/manifest.json +++ b/homeassistant/components/vera/manifest.json @@ -4,6 +4,7 @@ "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vera", + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyvera"], "requirements": ["pyvera==0.3.16"] diff --git a/homeassistant/components/verisure/binary_sensor.py b/homeassistant/components/verisure/binary_sensor.py index 4d9221c3ca97c1..c42454b380a7f8 100644 --- a/homeassistant/components/verisure/binary_sensor.py +++ b/homeassistant/components/verisure/binary_sensor.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -82,7 +84,7 @@ def available(self) -> bool: ) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the sensor.""" return { ATTR_LAST_TRIP_TIME: dt_util.parse_datetime( diff --git a/homeassistant/components/versasense/switch.py b/homeassistant/components/versasense/switch.py index 828dbf6d9afa1f..00c94d04045aaa 100644 --- a/homeassistant/components/versasense/switch.py +++ b/homeassistant/components/versasense/switch.py @@ -59,35 +59,18 @@ class VActuator(SwitchEntity): def __init__(self, peripheral, parent_name, unit, measurement, consumer): """Initialize the sensor.""" - self._is_on = False - self._available = True - self._name = f"{parent_name} {measurement}" + self._attr_is_on = False + self._attr_name = f"{parent_name} {measurement}" + self._attr_unique_id = ( + f"{peripheral.parentMac}/{peripheral.identifier}/{measurement}" + ) + self._parent_mac = peripheral.parentMac self._identifier = peripheral.identifier self._unit = unit self._measurement = measurement self.consumer = consumer - @property - def unique_id(self): - """Return the unique id of the actuator.""" - return f"{self._parent_mac}/{self._identifier}/{self._measurement}" - - @property - def name(self): - """Return the name of the actuator.""" - return self._name - - @property - def is_on(self): - """Return the state of the actuator.""" - return self._is_on - - @property - def available(self) -> bool: - """Return if the actuator is available.""" - return self._available - async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the actuator.""" await self.update_state(0) @@ -113,13 +96,13 @@ async def async_update(self) -> None: if samples is not None: for sample in samples: if sample.measurement == self._measurement: - self._available = True + self._attr_available = True if sample.value == PERIPHERAL_STATE_OFF: - self._is_on = False + self._attr_is_on = False elif sample.value == PERIPHERAL_STATE_ON: - self._is_on = True + self._attr_is_on = True break else: _LOGGER.error("Sample unavailable") - self._available = False - self._is_on = None + self._attr_available = False + self._attr_is_on = None diff --git a/homeassistant/components/version/diagnostics.py b/homeassistant/components/version/diagnostics.py index bcc94bd8da42ce..1174c5ad4d3893 100644 --- a/homeassistant/components/version/diagnostics.py +++ b/homeassistant/components/version/diagnostics.py @@ -6,6 +6,7 @@ from attr import asdict +from homeassistant.components.diagnostics import entity_entry_as_dict from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -42,7 +43,9 @@ async def async_get_config_entry_diagnostics( state_dict = dict(state.as_dict()) state_dict.pop("context", None) - entities.append({"entry": asdict(entity), "state": state_dict}) + entities.append( + {"entry": entity_entry_as_dict(entity), "state": state_dict} + ) devices.append({"device": asdict(device), "entities": entities}) diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index 2b96a7ad8e863a..8b4a83855e0319 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -6,14 +6,13 @@ import logging import os -from PyViCare.PyViCare import PyViCare from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig from PyViCare.PyViCareUtils import ( PyViCareInvalidConfigurationError, PyViCareInvalidCredentialsError, ) -from homeassistant.components.climate import DOMAIN as DOMAIN_CLIMATE +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -27,11 +26,28 @@ VICARE_TOKEN_FILENAME, ) from .types import ViCareConfigEntry, ViCareData, ViCareDevice -from .utils import get_device, get_device_serial, login +from .utils import get_device_serial, login _LOGGER = logging.getLogger(__name__) +async def async_migrate_entry( + hass: HomeAssistant, config_entry: ViCareConfigEntry +) -> bool: + """Migrate old entry.""" + if config_entry.version > 1: + return False + + if config_entry.version == 1 and config_entry.minor_version < 2: + _LOGGER.debug("Migrating ViCare config entry from version 1.1 to 1.2") + data = {**config_entry.data} + data.pop("heating_type", None) + hass.config_entries.async_update_entry(config_entry, data=data, minor_version=2) + _LOGGER.debug("Migration to version 1.2 successful") + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ViCareConfigEntry) -> bool: """Set up from config entry.""" _LOGGER.debug("Setting up ViCare component") @@ -51,7 +67,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ViCareConfigEntry) -> bo return True -def setup_vicare_api(hass: HomeAssistant, entry: ViCareConfigEntry) -> PyViCare: +def setup_vicare_api(hass: HomeAssistant, entry: ViCareConfigEntry) -> ViCareData: """Set up PyVicare API.""" client = login(hass, entry.data) @@ -74,7 +90,7 @@ def setup_vicare_api(hass: HomeAssistant, entry: ViCareConfigEntry) -> PyViCare: ) devices = [ - ViCareDevice(config=device_config, api=get_device(entry, device_config)) + ViCareDevice(config=device_config, api=device_config.asAutoDetectDevice()) for device_config in device_config_list if bool(device_config.isOnline()) ] @@ -145,7 +161,7 @@ async def async_migrate_devices_and_entities( # convert climate entity unique id # from `-` # to `-heating-` - if entity_entry.domain == DOMAIN_CLIMATE: + if entity_entry.domain == CLIMATE_DOMAIN: unique_id_parts[len(unique_id_parts) - 1] = ( f"{entity_entry.translation_key}-" f"{unique_id_parts[len(unique_id_parts) - 1]}" diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 940b27a4bc8d78..c5c1f6fbf944dd 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -12,12 +12,7 @@ from PyViCare.PyViCareHeatingDevice import ( HeatingDeviceWithComponent as PyViCareHeatingDeviceComponent, ) -from PyViCare.PyViCareUtils import ( - PyViCareInvalidDataError, - PyViCareNotSupportedFeatureError, - PyViCareRateLimitError, -) -import requests +from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -231,14 +226,5 @@ def available(self) -> bool: def update(self) -> None: """Update state of sensor.""" - try: - with suppress(PyViCareNotSupportedFeatureError): - self._attr_is_on = self.entity_description.value_getter(self._api) - except requests.exceptions.ConnectionError: - _LOGGER.error("Unable to retrieve data from ViCare server") - except ValueError: - _LOGGER.error("Unable to decode data from ViCare server") - except PyViCareRateLimitError as limit_exception: - _LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception) - except PyViCareInvalidDataError as invalid_data_exception: - _LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception) + with self.vicare_api_handler(), suppress(PyViCareNotSupportedFeatureError): + self._attr_is_on = self.entity_description.value_getter(self._api) diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index a1a7768ba3c619..852cf2a9062efd 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -8,12 +8,7 @@ from PyViCare.PyViCareDevice import Device as PyViCareDevice from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig -from PyViCare.PyViCareUtils import ( - PyViCareInvalidDataError, - PyViCareNotSupportedFeatureError, - PyViCareRateLimitError, -) -import requests +from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory @@ -102,14 +97,5 @@ def __init__( def press(self) -> None: """Handle the button press.""" - try: - with suppress(PyViCareNotSupportedFeatureError): - self.entity_description.value_setter(self._api) - except requests.exceptions.ConnectionError: - _LOGGER.error("Unable to retrieve data from ViCare server") - except ValueError: - _LOGGER.error("Unable to decode data from ViCare server") - except PyViCareRateLimitError as limit_exception: - _LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception) - except PyViCareInvalidDataError as invalid_data_exception: - _LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception) + with self.vicare_api_handler(), suppress(PyViCareNotSupportedFeatureError): + self.entity_description.value_setter(self._api) diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index d55c12087a0a57..9f23c60085e553 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -11,11 +11,8 @@ from PyViCare.PyViCareHeatingDevice import HeatingCircuit as PyViCareHeatingCircuit from PyViCare.PyViCareUtils import ( PyViCareCommandError, - PyViCareInvalidDataError, PyViCareNotSupportedFeatureError, - PyViCareRateLimitError, ) -import requests import voluptuous as vol from homeassistant.components.climate import ( @@ -158,7 +155,7 @@ def __init__( def update(self) -> None: """Let HA know there has been an update from the ViCare API.""" - try: + with self.vicare_api_handler(): _room_temperature = None with suppress(PyViCareNotSupportedFeatureError): self._attributes["room_temperature"] = _room_temperature = ( @@ -214,15 +211,6 @@ def update(self) -> None: self._current_action or compressor.getActive() ) - except requests.exceptions.ConnectionError: - _LOGGER.error("Unable to retrieve data from ViCare server") - except PyViCareRateLimitError as limit_exception: - _LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception) - except ValueError: - _LOGGER.error("Unable to decode data from ViCare server") - except PyViCareInvalidDataError as invalid_data_exception: - _LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception) - @property def hvac_mode(self) -> HVACMode | None: """Return current hvac mode.""" diff --git a/homeassistant/components/vicare/config_flow.py b/homeassistant/components/vicare/config_flow.py index 19e91ab6c8fe42..73ce51a2b8eeb6 100644 --- a/homeassistant/components/vicare/config_flow.py +++ b/homeassistant/components/vicare/config_flow.py @@ -18,14 +18,7 @@ from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .const import ( - CONF_HEATING_TYPE, - DEFAULT_HEATING_TYPE, - DOMAIN, - VICARE_NAME, - VIESSMANN_DEVELOPER_PORTAL, - HeatingType, -) +from .const import DOMAIN, VICARE_NAME, VIESSMANN_DEVELOPER_PORTAL from .utils import login _LOGGER = logging.getLogger(__name__) @@ -40,9 +33,6 @@ USER_SCHEMA = REAUTH_SCHEMA.extend( { vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_HEATING_TYPE, default=DEFAULT_HEATING_TYPE.value): vol.In( - [e.value for e in HeatingType] - ), } ) @@ -51,6 +41,7 @@ class ViCareConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for ViCare.""" VERSION = 1 + MINOR_VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/vicare/const.py b/homeassistant/components/vicare/const.py index ff57508c34b4ba..aeb52bd28ae678 100644 --- a/homeassistant/components/vicare/const.py +++ b/homeassistant/components/vicare/const.py @@ -1,7 +1,5 @@ """Constants for the ViCare integration.""" -import enum - from homeassistant.const import Platform DOMAIN = "vicare" @@ -12,6 +10,7 @@ Platform.CLIMATE, Platform.FAN, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.WATER_HEATER, ] @@ -30,7 +29,6 @@ VIESSMANN_DEVELOPER_PORTAL = "https://app.developer.viessmann-climatesolutions.com" CONF_CIRCUIT = "circuit" -CONF_HEATING_TYPE = "heating_type" DEFAULT_CACHE_DURATION = 60 @@ -42,28 +40,3 @@ VICARE_PERCENT = "percent" VICARE_W = "watt" VICARE_WH = "wattHour" - - -class HeatingType(enum.Enum): - """Possible options for heating type.""" - - auto = "auto" - gas = "gas" - oil = "oil" - pellets = "pellets" - heatpump = "heatpump" - fuelcell = "fuelcell" - hybrid = "hybrid" - - -DEFAULT_HEATING_TYPE = HeatingType.auto - -HEATING_TYPE_TO_CREATOR_METHOD = { - HeatingType.auto: "asAutoDetectDevice", - HeatingType.gas: "asGazBoiler", - HeatingType.fuelcell: "asFuelCell", - HeatingType.heatpump: "asHeatPump", - HeatingType.oil: "asOilBoiler", - HeatingType.pellets: "asPelletsBoiler", - HeatingType.hybrid: "asHybridDevice", -} diff --git a/homeassistant/components/vicare/entity.py b/homeassistant/components/vicare/entity.py index bcfda71cdfdafd..4502e12ff864ec 100644 --- a/homeassistant/components/vicare/entity.py +++ b/homeassistant/components/vicare/entity.py @@ -1,22 +1,53 @@ """Entities for the ViCare integration.""" +from collections.abc import Generator +from contextlib import contextmanager +import logging + from PyViCare.PyViCareDevice import Device as PyViCareDevice from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig from PyViCare.PyViCareHeatingDevice import ( HeatingDeviceWithComponent as PyViCareHeatingDeviceComponent, ) +from PyViCare.PyViCareUtils import ( + PyViCareDeviceCommunicationError, + PyViCareInternalServerError, + PyViCareInvalidDataError, + PyViCareRateLimitError, +) +from requests.exceptions import ConnectionError as RequestConnectionError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from .const import DOMAIN, VIESSMANN_DEVELOPER_PORTAL +_LOGGER = logging.getLogger(__name__) + class ViCareEntity(Entity): """Base class for ViCare entities.""" _attr_has_entity_name = True + @contextmanager + def vicare_api_handler(self) -> Generator[None]: + """Handle common ViCare API errors.""" + try: + yield + except RequestConnectionError: + _LOGGER.error("Unable to retrieve data from ViCare server") + except ValueError: + _LOGGER.error("Unable to decode data from ViCare server") + except PyViCareRateLimitError as err: + _LOGGER.error("ViCare API rate limit exceeded: %s", err) + except PyViCareInvalidDataError as err: + _LOGGER.error("Invalid data from ViCare server: %s", err) + except PyViCareDeviceCommunicationError as err: + _LOGGER.warning("Device communication error: %s", err) + except PyViCareInternalServerError as err: + _LOGGER.warning("ViCare server error: %s", err) + def __init__( self, unique_id_suffix: str, diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py index 88d42503a0324b..87fca8d6cf6133 100644 --- a/homeassistant/components/vicare/fan.py +++ b/homeassistant/components/vicare/fan.py @@ -9,12 +9,7 @@ from PyViCare.PyViCareDevice import Device as PyViCareDevice from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig -from PyViCare.PyViCareUtils import ( - PyViCareInvalidDataError, - PyViCareNotSupportedFeatureError, - PyViCareRateLimitError, -) -from requests.exceptions import ConnectionError as RequestConnectionError +from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.core import HomeAssistant @@ -171,7 +166,7 @@ def __init__( def update(self) -> None: """Update state of fan.""" level: str | None = None - try: + with self.vicare_api_handler(): with suppress(PyViCareNotSupportedFeatureError): self._attr_preset_mode = VentilationMode.from_vicare_mode( self._api.getActiveVentilationMode() @@ -185,14 +180,6 @@ def update(self) -> None: ) else: self._attr_percentage = 0 - except RequestConnectionError: - _LOGGER.error("Unable to retrieve data from ViCare server") - except ValueError: - _LOGGER.error("Unable to decode data from ViCare server") - except PyViCareRateLimitError as limit_exception: - _LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception) - except PyViCareInvalidDataError as invalid_data_exception: - _LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception) @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index c66616765e6621..4491ed9501a79c 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.56.0"] + "requirements": ["PyViCare==2.58.1"] } diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py index ba913bf194949f..de43b5a179744f 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -13,12 +13,7 @@ from PyViCare.PyViCareHeatingDevice import ( HeatingDeviceWithComponent as PyViCareHeatingDeviceComponent, ) -from PyViCare.PyViCareUtils import ( - PyViCareInvalidDataError, - PyViCareNotSupportedFeatureError, - PyViCareRateLimitError, -) -from requests.exceptions import ConnectionError as RequestConnectionError +from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError from homeassistant.components.number import ( NumberDeviceClass, @@ -435,34 +430,23 @@ def set_native_value(self, value: float) -> None: def update(self) -> None: """Update state of number.""" - try: - with suppress(PyViCareNotSupportedFeatureError): - self._attr_native_value = self.entity_description.value_getter( - self._api - ) + with self.vicare_api_handler(), suppress(PyViCareNotSupportedFeatureError): + self._attr_native_value = self.entity_description.value_getter(self._api) - if min_value := _get_value( - self.entity_description.min_value_getter, self._api - ): - self._attr_native_min_value = min_value + if min_value := _get_value( + self.entity_description.min_value_getter, self._api + ): + self._attr_native_min_value = min_value - if max_value := _get_value( - self.entity_description.max_value_getter, self._api - ): - self._attr_native_max_value = max_value + if max_value := _get_value( + self.entity_description.max_value_getter, self._api + ): + self._attr_native_max_value = max_value - if stepping_value := _get_value( - self.entity_description.stepping_getter, self._api - ): - self._attr_native_step = stepping_value - except RequestConnectionError: - _LOGGER.error("Unable to retrieve data from ViCare server") - except ValueError: - _LOGGER.error("Unable to decode data from ViCare server") - except PyViCareRateLimitError as limit_exception: - _LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception) - except PyViCareInvalidDataError as invalid_data_exception: - _LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception) + if stepping_value := _get_value( + self.entity_description.stepping_getter, self._api + ): + self._attr_native_step = stepping_value def _get_value( diff --git a/homeassistant/components/vicare/select.py b/homeassistant/components/vicare/select.py new file mode 100644 index 00000000000000..d94d3c606e3c11 --- /dev/null +++ b/homeassistant/components/vicare/select.py @@ -0,0 +1,117 @@ +"""Viessmann ViCare select device.""" + +from __future__ import annotations + +from contextlib import suppress +import logging + +from PyViCare.PyViCareDevice import Device as PyViCareDevice +from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig +from PyViCare.PyViCareUtils import ( + PyViCareInvalidDataError, + PyViCareNotSupportedFeatureError, + PyViCareRateLimitError, +) +import requests + +from homeassistant.components.select import SelectEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import ViCareEntity +from .types import ViCareConfigEntry, ViCareDevice +from .utils import get_device_serial, is_supported + +_LOGGER = logging.getLogger(__name__) + +# Map API values to snake_case for HA, and back +DHW_MODE_API_TO_HA: dict[str, str] = { + "efficient": "efficient", + "efficientWithMinComfort": "efficient_with_min_comfort", + "off": "off", +} +DHW_MODE_HA_TO_API: dict[str, str] = {v: k for k, v in DHW_MODE_API_TO_HA.items()} + + +def _build_entities( + device_list: list[ViCareDevice], +) -> list[ViCareDHWOperatingModeSelect]: + """Create ViCare select entities for a device.""" + return [ + ViCareDHWOperatingModeSelect( + get_device_serial(device.api), + device.config, + device.api, + ) + for device in device_list + if is_supported( + "dhw_operating_mode", + lambda api: api.getDomesticHotWaterActiveOperatingMode(), + device.api, + ) + ] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ViCareConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the ViCare select platform.""" + async_add_entities( + await hass.async_add_executor_job( + _build_entities, + config_entry.runtime_data.devices, + ) + ) + + +class ViCareDHWOperatingModeSelect(ViCareEntity, SelectEntity): + """Representation of the ViCare DHW operating mode select entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "dhw_operating_mode" + + def __init__( + self, + device_serial: str | None, + device_config: PyViCareDeviceConfig, + device: PyViCareDevice, + ) -> None: + """Initialize the DHW operating mode select entity.""" + super().__init__("dhw_operating_mode", device_serial, device_config, device) + self._attr_options = [ + DHW_MODE_API_TO_HA.get(mode, mode) + for mode in device.getDomesticHotWaterOperatingModes() + ] + active = device.getDomesticHotWaterActiveOperatingMode() + self._attr_current_option = DHW_MODE_API_TO_HA.get(active, active) + + def update(self) -> None: + """Update state from the ViCare API.""" + try: + with suppress(PyViCareNotSupportedFeatureError): + self._attr_options = [ + DHW_MODE_API_TO_HA.get(mode, mode) + for mode in self._api.getDomesticHotWaterOperatingModes() + ] + + with suppress(PyViCareNotSupportedFeatureError): + active = self._api.getDomesticHotWaterActiveOperatingMode() + self._attr_current_option = DHW_MODE_API_TO_HA.get(active, active) + except requests.exceptions.ConnectionError: + _LOGGER.error("Unable to retrieve data from ViCare server") + except PyViCareRateLimitError as limit_exception: + _LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception) + except ValueError: + _LOGGER.error("Unable to decode data from ViCare server") + except PyViCareInvalidDataError as invalid_data_exception: + _LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception) + + def select_option(self, option: str) -> None: + """Set the DHW operating mode.""" + api_mode = DHW_MODE_HA_TO_API.get(option, option) + self._api.setDomesticHotWaterOperatingMode(api_mode) + self._attr_current_option = option + self.schedule_update_ha_state() diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 01e03bab5be129..c981d94de318bd 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -12,12 +12,7 @@ from PyViCare.PyViCareHeatingDevice import ( HeatingDeviceWithComponent as PyViCareHeatingDeviceComponent, ) -from PyViCare.PyViCareUtils import ( - PyViCareInvalidDataError, - PyViCareNotSupportedFeatureError, - PyViCareRateLimitError, -) -import requests +from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError from homeassistant.components.sensor import ( SensorDeviceClass, @@ -168,6 +163,16 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), + ViCareSensorEntityDescription( + key="primary_circuit_pump_rotation", + translation_key="primary_circuit_pump_rotation", + native_unit_of_measurement=PERCENTAGE, + value_getter=lambda api: api.getPrimaryCircuitPumpRotation(), + unit_getter=lambda api: api.getPrimaryCircuitPumpRotationUnit(), + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), ViCareSensorEntityDescription( key="secondary_circuit_supply_temperature", translation_key="secondary_circuit_supply_temperature", @@ -184,6 +189,36 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), + ViCareSensorEntityDescription( + key="hot_gas_temperature", + translation_key="hot_gas_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getHotGasTemperature(), + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + ViCareSensorEntityDescription( + key="liquid_gas_temperature", + translation_key="liquid_gas_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getLiquidGasTemperature(), + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + ViCareSensorEntityDescription( + key="suction_gas_temperature", + translation_key="suction_gas_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getSuctionGasTemperature(), + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), ViCareSensorEntityDescription( key="hotwater_out_temperature", translation_key="hotwater_out_temperature", @@ -686,6 +721,26 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), + ViCareSensorEntityDescription( + key="energy_consumption_heating_this_year", + translation_key="energy_consumption_heating_this_year", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_getter=lambda api: api.getPowerConsumptionHeatingThisYear(), + unit_getter=lambda api: api.getPowerConsumptionHeatingUnit(), + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + ViCareSensorEntityDescription( + key="energy_consumption_dhw_this_year", + translation_key="energy_consumption_dhw_this_year", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_getter=lambda api: api.getPowerConsumptionDomesticHotWaterThisYear(), + unit_getter=lambda api: api.getPowerConsumptionDomesticHotWaterUnit(), + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), ViCareSensorEntityDescription( key="buffer top temperature", translation_key="buffer_top_temperature", @@ -971,6 +1026,28 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM value_getter=lambda api: api.getSupplyPressure(), unit_getter=lambda api: api.getSupplyPressureUnit(), ), + ViCareSensorEntityDescription( + key="hot_gas_pressure", + translation_key="hot_gas_pressure", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.BAR, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_getter=lambda api: api.getHotGasPressure(), + unit_getter=lambda api: api.getHotGasPressureUnit(), + entity_registry_enabled_default=False, + ), + ViCareSensorEntityDescription( + key="suction_gas_pressure", + translation_key="suction_gas_pressure", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.BAR, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_getter=lambda api: api.getSuctionGasPressure(), + unit_getter=lambda api: api.getSuctionGasPressureUnit(), + entity_registry_enabled_default=False, + ), ViCareSensorEntityDescription( key="heating_rod_starts", translation_key="heating_rod_starts", @@ -1007,6 +1084,35 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM entity_category=EntityCategory.DIAGNOSTIC, value_getter=lambda api: api.getSeasonalPerformanceFactorHeating(), ), + ViCareSensorEntityDescription( + key="cop_heating", + translation_key="cop_heating", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_getter=lambda api: api.getCoefficientOfPerformanceHeating(), + ), + ViCareSensorEntityDescription( + key="cop_dhw", + translation_key="cop_dhw", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_getter=lambda api: api.getCoefficientOfPerformanceDHW(), + ), + ViCareSensorEntityDescription( + key="cop_total", + translation_key="cop_total", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_getter=lambda api: api.getCoefficientOfPerformanceTotal(), + ), + ViCareSensorEntityDescription( + key="cop_cooling", + translation_key="cop_cooling", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_getter=lambda api: api.getCoefficientOfPerformanceCooling(), + entity_registry_enabled_default=False, + ), ViCareSensorEntityDescription( key="battery_level", native_unit_of_measurement=PERCENTAGE, @@ -1187,6 +1293,23 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ) COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( + ViCareSensorEntityDescription( + key="compressor_power", + translation_key="compressor_power", + native_unit_of_measurement=UnitOfPower.KILO_WATT, + value_getter=lambda api: api.getPower(), + unit_getter=lambda api: api.getPowerUnit(), + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + ), + ViCareSensorEntityDescription( + key="compressor_modulation", + translation_key="compressor_modulation", + native_unit_of_measurement=PERCENTAGE, + value_getter=lambda api: api.getModulation(), + unit_getter=lambda api: api.getModulationUnit(), + state_class=SensorStateClass.MEASUREMENT, + ), ViCareSensorEntityDescription( key="compressor_starts", translation_key="compressor_starts", @@ -1446,22 +1569,11 @@ def available(self) -> bool: def update(self) -> None: """Update state of sensor.""" vicare_unit = None - try: - with suppress(PyViCareNotSupportedFeatureError): - self._attr_native_value = self.entity_description.value_getter( - self._api - ) + with self.vicare_api_handler(), suppress(PyViCareNotSupportedFeatureError): + self._attr_native_value = self.entity_description.value_getter(self._api) - if self.entity_description.unit_getter: - vicare_unit = self.entity_description.unit_getter(self._api) - except requests.exceptions.ConnectionError: - _LOGGER.error("Unable to retrieve data from ViCare server") - except ValueError: - _LOGGER.error("Unable to decode data from ViCare server") - except PyViCareRateLimitError as limit_exception: - _LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception) - except PyViCareInvalidDataError as invalid_data_exception: - _LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception) + if self.entity_description.unit_getter: + vicare_unit = self.entity_description.unit_getter(self._api) if vicare_unit is not None: if ( diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 6b313eb1872e9e..f974580f9af8d3 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -24,13 +24,11 @@ "user": { "data": { "client_id": "Client ID", - "heating_type": "Heating type", "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::email%]" }, "data_description": { "client_id": "The ID of the API client created in the [Viessmann developer portal]({viessmann_developer_portal}).", - "heating_type": "Allows to overrule the device auto detection.", "password": "The password to log in to your ViCare account.", "username": "The email address to log in to your ViCare account." }, @@ -160,6 +158,16 @@ "name": "Reduced temperature" } }, + "select": { + "dhw_operating_mode": { + "name": "DHW operating mode", + "state": { + "efficient": "Efficient", + "efficient_with_min_comfort": "Efficient with minimum comfort", + "off": "[%key:common::state::off%]" + } + } + }, "sensor": { "boiler_supply_temperature": { "name": "Boiler supply temperature" @@ -221,6 +229,9 @@ "compressor_inlet_temperature": { "name": "Compressor inlet temperature" }, + "compressor_modulation": { + "name": "Compressor modulation" + }, "compressor_outlet_pressure": { "name": "Compressor outlet pressure" }, @@ -241,6 +252,9 @@ "ready": "[%key:common::state::idle%]" } }, + "compressor_power": { + "name": "Compressor power" + }, "compressor_starts": { "name": "Compressor starts" }, @@ -250,6 +264,18 @@ "condenser_subcooling_temperature": { "name": "Condenser subcooling temperature" }, + "cop_cooling": { + "name": "Coefficient of performance - cooling" + }, + "cop_dhw": { + "name": "Coefficient of performance - domestic hot water" + }, + "cop_heating": { + "name": "Coefficient of performance - heating" + }, + "cop_total": { + "name": "Coefficient of performance" + }, "dhw_storage_bottom_temperature": { "name": "DHW storage bottom temperature" }, @@ -271,6 +297,12 @@ "energy_consumption_cooling_today": { "name": "Cooling electricity consumption today" }, + "energy_consumption_dhw_this_year": { + "name": "DHW energy consumption this year" + }, + "energy_consumption_heating_this_year": { + "name": "Heating energy consumption this year" + }, "energy_dhw_summary_consumption_heating_currentday": { "name": "DHW electricity consumption today" }, @@ -396,6 +428,12 @@ "heating_rod_starts": { "name": "Heating rod starts" }, + "hot_gas_pressure": { + "name": "Hot gas pressure" + }, + "hot_gas_temperature": { + "name": "Hot gas temperature" + }, "hotwater_gas_consumption_heating_this_month": { "name": "DHW gas consumption this month" }, @@ -441,6 +479,9 @@ "inverter_temperature": { "name": "Inverter temperature" }, + "liquid_gas_temperature": { + "name": "Liquid gas temperature" + }, "outside_humidity": { "name": "Outside humidity" }, @@ -508,6 +549,9 @@ "power_production_today": { "name": "Energy production today" }, + "primary_circuit_pump_rotation": { + "name": "Primary circuit pump rotation" + }, "primary_circuit_return_temperature": { "name": "Primary circuit return temperature" }, @@ -547,6 +591,12 @@ "spf_total": { "name": "Seasonal performance factor" }, + "suction_gas_pressure": { + "name": "Suction gas pressure" + }, + "suction_gas_temperature": { + "name": "Suction gas temperature" + }, "supply_fan_hours": { "name": "Supply fan hours" }, diff --git a/homeassistant/components/vicare/utils.py b/homeassistant/components/vicare/utils.py index ea0386c03e357b..bf1ff9277fe50d 100644 --- a/homeassistant/components/vicare/utils.py +++ b/homeassistant/components/vicare/utils.py @@ -8,11 +8,12 @@ from PyViCare.PyViCare import PyViCare from PyViCare.PyViCareDevice import Device as PyViCareDevice -from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig from PyViCare.PyViCareHeatingDevice import ( HeatingDeviceWithComponent as PyViCareHeatingDeviceComponent, ) from PyViCare.PyViCareUtils import ( + PyViCareDeviceCommunicationError, + PyViCareInternalServerError, PyViCareInvalidDataError, PyViCareNotSupportedFeatureError, PyViCareRateLimitError, @@ -23,14 +24,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.storage import STORAGE_DIR -from .const import ( - CONF_HEATING_TYPE, - DEFAULT_CACHE_DURATION, - HEATING_TYPE_TO_CREATOR_METHOD, - VICARE_TOKEN_FILENAME, - HeatingType, -) -from .types import ViCareConfigEntry +from .const import DEFAULT_CACHE_DURATION, VICARE_TOKEN_FILENAME _LOGGER = logging.getLogger(__name__) @@ -52,16 +46,6 @@ def login( return vicare_api -def get_device( - entry: ViCareConfigEntry, device_config: PyViCareDeviceConfig -) -> PyViCareDevice: - """Get device for device config.""" - return getattr( - device_config, - HEATING_TYPE_TO_CREATOR_METHOD[HeatingType(entry.data[CONF_HEATING_TYPE])], - )() - - def get_device_serial(device: PyViCareDevice) -> str | None: """Get device serial for device if supported.""" try: @@ -72,6 +56,10 @@ def get_device_serial(device: PyViCareDevice) -> str | None: _LOGGER.debug("Vicare API rate limit exceeded: %s", limit_exception) except PyViCareInvalidDataError as invalid_data_exception: _LOGGER.debug("Invalid data from Vicare server: %s", invalid_data_exception) + except PyViCareDeviceCommunicationError as comm_exception: + _LOGGER.debug("Device communication error: %s", comm_exception) + except PyViCareInternalServerError as server_exception: + _LOGGER.debug("Vicare server error: %s", server_exception) except requests.exceptions.ConnectionError: _LOGGER.debug("Unable to retrieve data from ViCare server") except ValueError: diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index ef06317c482ac7..7693f63b3ae2c0 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -9,12 +9,7 @@ from PyViCare.PyViCareDevice import Device as PyViCareDevice from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig from PyViCare.PyViCareHeatingDevice import HeatingCircuit as PyViCareHeatingCircuit -from PyViCare.PyViCareUtils import ( - PyViCareInvalidDataError, - PyViCareNotSupportedFeatureError, - PyViCareRateLimitError, -) -import requests +from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError from homeassistant.components.water_heater import ( WaterHeaterEntity, @@ -118,7 +113,7 @@ def __init__( def update(self) -> None: """Let HA know there has been an update from the ViCare API.""" - try: + with self.vicare_api_handler(): with suppress(PyViCareNotSupportedFeatureError): self._attr_current_temperature = ( self._api.getDomesticHotWaterStorageTemperature() @@ -135,15 +130,6 @@ def update(self) -> None: with suppress(PyViCareNotSupportedFeatureError): self._dhw_active = self._api.getDomesticHotWaterActive() - except requests.exceptions.ConnectionError: - _LOGGER.error("Unable to retrieve data from ViCare server") - except PyViCareRateLimitError as limit_exception: - _LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception) - except ValueError: - _LOGGER.error("Unable to decode data from ViCare server") - except PyViCareInvalidDataError as invalid_data_exception: - _LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception) - def set_temperature(self, **kwargs: Any) -> None: """Set new target temperatures.""" if (temp := kwargs.get(ATTR_TEMPERATURE)) is not None: diff --git a/homeassistant/components/victron_ble/__init__.py b/homeassistant/components/victron_ble/__init__.py index 20c524d1f9c5a2..7eff058b7b229a 100644 --- a/homeassistant/components/victron_ble/__init__.py +++ b/homeassistant/components/victron_ble/__init__.py @@ -4,10 +4,12 @@ import logging +from sensor_state_data import SensorUpdate from victron_ble_ha_parser import VictronBluetoothDeviceData from homeassistant.components.bluetooth import ( BluetoothScanningMode, + BluetoothServiceInfoBleak, async_rediscover_address, ) from homeassistant.components.bluetooth.passive_update_processor import ( @@ -17,6 +19,8 @@ from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant +from .const import REAUTH_AFTER_FAILURES + _LOGGER = logging.getLogger(__name__) @@ -26,12 +30,38 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: assert address is not None key = entry.data[CONF_ACCESS_TOKEN] data = VictronBluetoothDeviceData(key) + consecutive_failures = 0 + + def _update( + service_info: BluetoothServiceInfoBleak, + ) -> SensorUpdate: + nonlocal consecutive_failures + update = data.update(service_info) + + # If the device type was recognized (devices dict populated) but + # only signal strength came back, decryption likely failed. + # Unsupported devices have an empty devices dict and won't trigger this. + if update.devices and len(update.entity_values) <= 1: + consecutive_failures += 1 + if consecutive_failures >= REAUTH_AFTER_FAILURES: + _LOGGER.debug( + "Triggering reauth for %s after %d consecutive failures", + address, + consecutive_failures, + ) + entry.async_start_reauth(hass) + consecutive_failures = 0 + else: + consecutive_failures = 0 + + return update + coordinator = PassiveBluetoothProcessorCoordinator( hass, _LOGGER, address=address, mode=BluetoothScanningMode.ACTIVE, - update_method=data.update, + update_method=_update, ) entry.runtime_data = coordinator diff --git a/homeassistant/components/victron_ble/config_flow.py b/homeassistant/components/victron_ble/config_flow.py index eaf0bbab225e33..bde04783a4f1c9 100644 --- a/homeassistant/components/victron_ble/config_flow.py +++ b/homeassistant/components/victron_ble/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -66,6 +67,7 @@ async def async_step_access_token( discovery_info = self._discovered_devices_info[self._discovered_device] title = discovery_info.name + errors: dict[str, str] = {} if user_input is not None: # see if we can create a device with the access token device = VictronBluetoothDeviceData(user_input[CONF_ACCESS_TOKEN]) @@ -76,12 +78,13 @@ async def async_step_access_token( title=title, data=user_input, ) - return self.async_abort(reason="invalid_access_token") + errors["base"] = "invalid_access_token" return self.async_show_form( step_id="access_token", data_schema=STEP_ACCESS_TOKEN_DATA_SCHEMA, description_placeholders={"title": title}, + errors=errors, ) async def async_step_user( @@ -121,3 +124,42 @@ async def async_step_user( {vol.Required(CONF_ADDRESS): vol.In(self._discovered_devices)} ), ) + + async def async_step_reauth( + self, _entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle a flow initialized by a reauth event.""" + 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 with a new encryption key.""" + reauth_entry = self._get_reauth_entry() + errors: dict[str, str] = {} + + if user_input is not None: + device = VictronBluetoothDeviceData(user_input[CONF_ACCESS_TOKEN]) + + # Find the current advertisement data for this device + for discovery_info in async_discovered_service_info(self.hass, False): + if discovery_info.address == reauth_entry.unique_id: + mfr_data = discovery_info.manufacturer_data.get(VICTRON_IDENTIFIER) + if mfr_data is None or not device.validate_advertisement_key( + mfr_data + ): + errors["base"] = "invalid_access_token" + break + return self.async_update_reload_and_abort( + reauth_entry, + data_updates={CONF_ACCESS_TOKEN: user_input[CONF_ACCESS_TOKEN]}, + ) + else: + errors["base"] = "no_devices_found" + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_ACCESS_TOKEN_DATA_SCHEMA, + description_placeholders={"title": reauth_entry.title}, + errors=errors, + ) diff --git a/homeassistant/components/victron_ble/const.py b/homeassistant/components/victron_ble/const.py index 8ea195eb17a1ea..0da97bedaa2452 100644 --- a/homeassistant/components/victron_ble/const.py +++ b/homeassistant/components/victron_ble/const.py @@ -1,4 +1,5 @@ """Constants for the Victron Bluetooth Low Energy integration.""" DOMAIN = "victron_ble" +REAUTH_AFTER_FAILURES = 3 VICTRON_IDENTIFIER = 0x02E1 diff --git a/homeassistant/components/victron_ble/manifest.json b/homeassistant/components/victron_ble/manifest.json index 968fd27dec0ff1..85455f039e9f38 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.4.9"] + "requirements": ["victron-ble-ha-parser==0.6.2"] } diff --git a/homeassistant/components/victron_ble/sensor.py b/homeassistant/components/victron_ble/sensor.py index 0b5916b23ca6a4..18a112ab7005b0 100644 --- a/homeassistant/components/victron_ble/sensor.py +++ b/homeassistant/components/victron_ble/sensor.py @@ -44,6 +44,7 @@ ] ALARM_OPTIONS = [ + "no_alarm", "low_voltage", "high_voltage", "low_soc", @@ -147,7 +148,10 @@ def error_to_state(value: float | str | None) -> str | None: "network_c": "network", "network_d": "network", } - return value_map.get(value) + mapped = value_map.get(value) + if mapped is not None: + return mapped + return value if isinstance(value, str) and value in CHARGER_ERROR_OPTIONS else None DEVICE_STATE_OPTIONS = [ @@ -336,6 +340,7 @@ class VictronBLESensorEntityDescription(SensorEntityDescription): "switched_off_register", "remote_input", "protection_active", + "load_output_disabled", "pay_as_you_go_out_of_credit", "bms", "engine_shutdown", @@ -396,7 +401,7 @@ class VictronBLESensorEntityDescription(SensorEntityDescription): Keys.WARNING: VictronBLESensorEntityDescription( key=Keys.WARNING, device_class=SensorDeviceClass.ENUM, - translation_key="alarm", + translation_key="warning", options=ALARM_OPTIONS, ), Keys.YIELD_TODAY: VictronBLESensorEntityDescription( @@ -406,9 +411,64 @@ class VictronBLESensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), + Keys.AC_CURRENT: VictronBLESensorEntityDescription( + key=Keys.AC_CURRENT, + translation_key=Keys.AC_CURRENT, + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + ), + Keys.OUTPUT_VOLTAGE_1: VictronBLESensorEntityDescription( + key=Keys.OUTPUT_VOLTAGE_1, + translation_key="output_phase_voltage", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + translation_placeholders={"phase": "1"}, + ), + Keys.OUTPUT_CURRENT_1: VictronBLESensorEntityDescription( + key=Keys.OUTPUT_CURRENT_1, + translation_key="output_phase_current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + translation_placeholders={"phase": "1"}, + ), + Keys.OUTPUT_VOLTAGE_2: VictronBLESensorEntityDescription( + key=Keys.OUTPUT_VOLTAGE_2, + translation_key="output_phase_voltage", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + translation_placeholders={"phase": "2"}, + ), + Keys.OUTPUT_CURRENT_2: VictronBLESensorEntityDescription( + key=Keys.OUTPUT_CURRENT_2, + translation_key="output_phase_current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + translation_placeholders={"phase": "2"}, + ), + Keys.OUTPUT_VOLTAGE_3: VictronBLESensorEntityDescription( + key=Keys.OUTPUT_VOLTAGE_3, + translation_key="output_phase_voltage", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + translation_placeholders={"phase": "3"}, + ), + Keys.OUTPUT_CURRENT_3: VictronBLESensorEntityDescription( + key=Keys.OUTPUT_CURRENT_3, + translation_key="output_phase_current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + translation_placeholders={"phase": "3"}, + ), } -for i in range(1, 8): +for i in range(1, 9): cell_key = getattr(Keys, f"CELL_{i}_VOLTAGE") SENSOR_DESCRIPTIONS[cell_key] = VictronBLESensorEntityDescription( key=cell_key, @@ -416,6 +476,7 @@ class VictronBLESensorEntityDescription(SensorEntityDescription): device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, + translation_placeholders={"cell": str(i)}, ) diff --git a/homeassistant/components/victron_ble/strings.json b/homeassistant/components/victron_ble/strings.json index 1553d373213cbb..c599e61a83ae54 100644 --- a/homeassistant/components/victron_ble/strings.json +++ b/homeassistant/components/victron_ble/strings.json @@ -8,6 +8,13 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "invalid_access_token": "Invalid encryption key for instant readout", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "not_supported": "Device not supported", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, + "error": { "invalid_access_token": "Invalid encryption key for instant readout", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" }, @@ -22,6 +29,15 @@ }, "title": "{title}" }, + "reauth_confirm": { + "data": { + "access_token": "[%key:component::victron_ble::config::step::access_token::data::access_token%]" + }, + "data_description": { + "access_token": "[%key:component::victron_ble::config::step::access_token::data_description::access_token%]" + }, + "description": "The encryption key for {title} is invalid or has changed. Please enter the correct key." + }, "user": { "data": { "address": "The Bluetooth address of the Victron device." @@ -34,6 +50,9 @@ }, "entity": { "sensor": { + "ac_current": { + "name": "AC current" + }, "ac_in_power": { "name": "AC-in power" }, @@ -63,6 +82,7 @@ "low_v_ac_out": "AC-out undervoltage", "low_voltage": "Undervoltage", "mid_voltage": "[%key:component::victron_ble::common::midpoint_voltage%]", + "no_alarm": "No alarm", "overload": "Overload", "short_circuit": "Short circuit" } @@ -224,6 +244,7 @@ "analysing_input_voltage": "Analyzing input voltage", "bms": "Battery management system", "engine_shutdown": "Engine shutdown", + "load_output_disabled": "Load output disabled", "no_input_power": "No input power", "no_reason": "No reason", "pay_as_you_go_out_of_credit": "Pay-as-you-go out of credit", @@ -233,6 +254,12 @@ "switched_off_switch": "Switched off by switch" } }, + "output_phase_current": { + "name": "Output phase {phase} current" + }, + "output_phase_voltage": { + "name": "Output phase {phase} voltage" + }, "output_voltage": { "name": "Output voltage" }, @@ -246,7 +273,24 @@ "name": "[%key:component::victron_ble::common::starter_voltage%]" }, "warning": { - "name": "Warning" + "name": "Warning", + "state": { + "bms_lockout": "[%key:component::victron_ble::entity::sensor::alarm::state::bms_lockout%]", + "dc_ripple": "[%key:component::victron_ble::entity::sensor::alarm::state::dc_ripple%]", + "high_starter_voltage": "[%key:component::victron_ble::entity::sensor::alarm::state::high_starter_voltage%]", + "high_temperature": "[%key:component::victron_ble::entity::sensor::alarm::state::high_temperature%]", + "high_v_ac_out": "[%key:component::victron_ble::entity::sensor::alarm::state::high_v_ac_out%]", + "high_voltage": "[%key:component::victron_ble::entity::sensor::alarm::state::high_voltage%]", + "low_soc": "[%key:component::victron_ble::entity::sensor::alarm::state::low_soc%]", + "low_starter_voltage": "[%key:component::victron_ble::entity::sensor::alarm::state::low_starter_voltage%]", + "low_temperature": "[%key:component::victron_ble::entity::sensor::alarm::state::low_temperature%]", + "low_v_ac_out": "[%key:component::victron_ble::entity::sensor::alarm::state::low_v_ac_out%]", + "low_voltage": "[%key:component::victron_ble::entity::sensor::alarm::state::low_voltage%]", + "mid_voltage": "[%key:component::victron_ble::common::midpoint_voltage%]", + "no_alarm": "[%key:component::victron_ble::entity::sensor::alarm::state::no_alarm%]", + "overload": "[%key:component::victron_ble::entity::sensor::alarm::state::overload%]", + "short_circuit": "[%key:component::victron_ble::entity::sensor::alarm::state::short_circuit%]" + } }, "yield_today": { "name": "Yield today" diff --git a/homeassistant/components/vilfo/manifest.json b/homeassistant/components/vilfo/manifest.json index 9fa52072ddf1ee..7c11a65806d3c9 100644 --- a/homeassistant/components/vilfo/manifest.json +++ b/homeassistant/components/vilfo/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@ManneW"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vilfo", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["vilfo"], "requirements": ["vilfo-api-client==0.5.0"] diff --git a/homeassistant/components/vivotek/manifest.json b/homeassistant/components/vivotek/manifest.json index 360cf73a7a7a8c..2e56cf13c2f75f 100644 --- a/homeassistant/components/vivotek/manifest.json +++ b/homeassistant/components/vivotek/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@HarlemSquirrel"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vivotek", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["libpyvivotek"], "requirements": ["libpyvivotek==0.6.1"] diff --git a/homeassistant/components/vizio/__init__.py b/homeassistant/components/vizio/__init__.py index fbf7c6d16e13ab..ecf0342ae2f86d 100644 --- a/homeassistant/components/vizio/__init__.py +++ b/homeassistant/components/vizio/__init__.py @@ -2,20 +2,34 @@ from __future__ import annotations -from typing import Any +from pyvizio import VizioAsync from homeassistant.components.media_player import MediaPlayerDeviceClass -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE_CLASS, Platform +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_DEVICE_CLASS, + CONF_HOST, + CONF_NAME, + 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.storage import Store from homeassistant.helpers.typing import ConfigType - -from .const import CONF_APPS, DOMAIN -from .coordinator import VizioAppsDataUpdateCoordinator +from homeassistant.util.hass_dict import HassKey + +from .const import DEFAULT_TIMEOUT, DEVICE_ID, DOMAIN, VIZIO_DEVICE_CLASSES +from .coordinator import ( + VizioAppsDataUpdateCoordinator, + VizioConfigEntry, + VizioDeviceCoordinator, + VizioRuntimeData, +) from .services import async_setup_services +DATA_APPS: HassKey[VizioAppsDataUpdateCoordinator] = HassKey(f"{DOMAIN}_apps") + CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [Platform.MEDIA_PLAYER] @@ -26,36 +40,54 @@ 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: VizioConfigEntry) -> bool: """Load the saved entities.""" + host = entry.data[CONF_HOST] + token = entry.data.get(CONF_ACCESS_TOKEN) + device_class = entry.data[CONF_DEVICE_CLASS] + + # Create device + device = VizioAsync( + DEVICE_ID, + host, + entry.data[CONF_NAME], + auth_token=token, + device_type=VIZIO_DEVICE_CLASSES[device_class], + session=async_get_clientsession(hass, False), + timeout=DEFAULT_TIMEOUT, + ) - hass.data.setdefault(DOMAIN, {}) - if ( - CONF_APPS not in hass.data[DOMAIN] - and entry.data[CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV - ): - store: Store[list[dict[str, Any]]] = Store(hass, 1, DOMAIN) - coordinator = VizioAppsDataUpdateCoordinator(hass, entry, store) - await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][CONF_APPS] = coordinator + # Create device coordinator + device_coordinator = VizioDeviceCoordinator(hass, entry, device) + await device_coordinator.async_config_entry_first_refresh() + + # Create apps coordinator for TVs (shared across entries) + if device_class == MediaPlayerDeviceClass.TV and DATA_APPS not in hass.data: + apps_coordinator = VizioAppsDataUpdateCoordinator(hass, Store(hass, 1, DOMAIN)) + await apps_coordinator.async_setup() + hass.data[DATA_APPS] = apps_coordinator + await apps_coordinator.async_refresh() + + entry.runtime_data = VizioRuntimeData( + device_coordinator=device_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, entry: VizioConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - if not any( - entry.data[CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV - for entry in hass.config_entries.async_loaded_entries(DOMAIN) - ): - hass.data[DOMAIN].pop(CONF_APPS, None) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if not hass.data[DOMAIN]: - hass.data.pop(DOMAIN) + # Clean up apps coordinator if no TV entries remain + if unload_ok and not any( + e.data[CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV + for e in hass.config_entries.async_loaded_entries(DOMAIN) + if e.entry_id != entry.entry_id + ): + if apps_coordinator := hass.data.pop(DATA_APPS, None): + await apps_coordinator.async_shutdown() return unload_ok diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py index fa01b75de5ed4f..95f649e705980e 100644 --- a/homeassistant/components/vizio/config_flow.py +++ b/homeassistant/components/vizio/config_flow.py @@ -8,13 +8,12 @@ from typing import Any from pyvizio import VizioAsync, async_guess_device_type -from pyvizio.const import APP_HOME +from pyvizio.const import APP_HOME, APPS import voluptuous as vol from homeassistant.components.media_player import MediaPlayerDeviceClass from homeassistant.config_entries import ( SOURCE_ZEROCONF, - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, @@ -34,6 +33,7 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util.network import is_ip_address +from . import DATA_APPS from .const import ( CONF_APPS, CONF_APPS_TO_INCLUDE_OR_EXCLUDE, @@ -45,6 +45,7 @@ DEVICE_ID, DOMAIN, ) +from .coordinator import VizioConfigEntry _LOGGER = logging.getLogger(__name__) @@ -106,6 +107,14 @@ def _host_is_same(host1: str, host2: str) -> bool: class VizioOptionsConfigFlow(OptionsFlow): """Handle Vizio options.""" + def _get_app_list(self) -> list[dict[str, Any]]: + """Return the current apps list, falling back to defaults.""" + if ( + apps_coordinator := self.hass.data.get(DATA_APPS) + ) and apps_coordinator.data: + return apps_coordinator.data + return APPS + async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -157,10 +166,7 @@ async def async_step_init( ): cv.multi_select( [ APP_HOME["name"], - *( - app["name"] - for app in self.hass.data[DOMAIN][CONF_APPS].data - ), + *(app["name"] for app in self._get_app_list()), ] ), } @@ -176,7 +182,9 @@ class VizioConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> VizioOptionsConfigFlow: + def async_get_options_flow( + config_entry: VizioConfigEntry, + ) -> VizioOptionsConfigFlow: """Get the options flow for this handler.""" return VizioOptionsConfigFlow() diff --git a/homeassistant/components/vizio/coordinator.py b/homeassistant/components/vizio/coordinator.py index 0f95c8a53b707f..ca8a64699c7802 100644 --- a/homeassistant/components/vizio/coordinator.py +++ b/homeassistant/components/vizio/coordinator.py @@ -2,40 +2,164 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import logging -from typing import Any +from typing import TYPE_CHECKING, Any -from pyvizio.const import APPS +from pyvizio import VizioAsync +from pyvizio.api.apps import AppConfig +from pyvizio.api.input import InputItem +from pyvizio.const import APPS, INPUT_APPS from pyvizio.util import gen_apps_list_from_url +from homeassistant.components.media_player import MediaPlayerDeviceClass from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE_CLASS, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.storage import Store -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN +from .const import DOMAIN, VIZIO_AUDIO_SETTINGS, VIZIO_SOUND_MODE + +type VizioConfigEntry = ConfigEntry[VizioRuntimeData] _LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(seconds=30) + + +@dataclass(frozen=True) +class VizioRuntimeData: + """Runtime data for Vizio integration.""" + + device_coordinator: VizioDeviceCoordinator + + +@dataclass(frozen=True) +class VizioDeviceData: + """Raw data fetched from Vizio device.""" + + # Power state + is_on: bool + + # Audio settings from get_all_settings("audio") + audio_settings: dict[str, Any] | None = None + + # Sound mode options from get_setting_options("audio", "eq") + sound_mode_list: list[str] | None = None + + # Current input from get_current_input() + current_input: str | None = None + + # Available inputs from get_inputs_list() + input_list: list[InputItem] | None = None + + # Current app config from get_current_app_config() (TVs only) + current_app_config: AppConfig | None = None + + +class VizioDeviceCoordinator(DataUpdateCoordinator[VizioDeviceData]): + """Coordinator for Vizio device data.""" + + config_entry: VizioConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: VizioConfigEntry, + device: VizioAsync, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self.device = device + + async def _async_setup(self) -> None: + """Fetch device info and update device registry.""" + model = await self.device.get_model_name(log_api_exception=False) + version = await self.device.get_version(log_api_exception=False) + + if TYPE_CHECKING: + assert self.config_entry.unique_id + + device_registry = dr.async_get(self.hass) + device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + identifiers={(DOMAIN, self.config_entry.unique_id)}, + manufacturer="VIZIO", + name=self.config_entry.data[CONF_NAME], + model=model, + sw_version=version, + ) + + async def _async_update_data(self) -> VizioDeviceData: + """Fetch all device data.""" + is_on = await self.device.get_power_state(log_api_exception=False) + + if is_on is None: + raise UpdateFailed( + f"Unable to connect to {self.config_entry.data[CONF_HOST]}" + ) + + if not is_on: + return VizioDeviceData(is_on=False) + + # Device is on - fetch all data + audio_settings = await self.device.get_all_settings( + VIZIO_AUDIO_SETTINGS, log_api_exception=False + ) + + sound_mode_list = None + if audio_settings and VIZIO_SOUND_MODE in audio_settings: + sound_mode_list = await self.device.get_setting_options( + VIZIO_AUDIO_SETTINGS, VIZIO_SOUND_MODE, log_api_exception=False + ) + + current_input = await self.device.get_current_input(log_api_exception=False) + input_list = await self.device.get_inputs_list(log_api_exception=False) + + current_app_config = None + # Only attempt to fetch app config if the device is a TV and supports apps + if ( + self.config_entry.data[CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV + and input_list + and any(input_item.name in INPUT_APPS for input_item in input_list) + ): + current_app_config = await self.device.get_current_app_config( + log_api_exception=False + ) + + return VizioDeviceData( + is_on=True, + audio_settings=audio_settings, + sound_mode_list=sound_mode_list, + current_input=current_input, + input_list=input_list, + current_app_config=current_app_config, + ) + class VizioAppsDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): """Define an object to hold Vizio app config data.""" - config_entry: ConfigEntry - def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, store: Store[list[dict[str, Any]]], ) -> None: """Initialize.""" super().__init__( hass, _LOGGER, - config_entry=config_entry, + config_entry=None, name=DOMAIN, update_interval=timedelta(days=1), ) @@ -43,8 +167,9 @@ def __init__( self.fail_threshold = 10 self.store = store - async def _async_setup(self) -> None: - """Refresh data for the first time when a config entry is setup.""" + async def async_setup(self) -> None: + """Load initial data from storage and register shutdown.""" + await self.async_register_shutdown() self.data = await self.store.async_load() or APPS async def _async_update_data(self) -> list[dict[str, Any]]: diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index 424ce958ebc30b..1a0b439b0e9dec 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -2,11 +2,7 @@ from __future__ import annotations -from datetime import timedelta -import logging - -from pyvizio import AppConfig, VizioAsync -from pyvizio.api.apps import find_app_name +from pyvizio.api.apps import AppConfig, find_app_name from pyvizio.const import APP_HOME, INPUT_APPS, NO_APP_RUNNING, UNKNOWN_APP from homeassistant.components.media_player import ( @@ -15,58 +11,45 @@ MediaPlayerEntityFeature, MediaPlayerState, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_ACCESS_TOKEN, - CONF_DEVICE_CLASS, - CONF_EXCLUDE, - CONF_HOST, - CONF_INCLUDE, - CONF_NAME, -) +from homeassistant.const import CONF_DEVICE_CLASS, CONF_EXCLUDE, CONF_INCLUDE from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.aiohttp_client import async_get_clientsession 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 +from . import DATA_APPS from .const import ( CONF_ADDITIONAL_CONFIGS, CONF_APPS, CONF_VOLUME_STEP, - DEFAULT_TIMEOUT, DEFAULT_VOLUME_STEP, - DEVICE_ID, DOMAIN, SUPPORTED_COMMANDS, VIZIO_AUDIO_SETTINGS, - VIZIO_DEVICE_CLASSES, VIZIO_MUTE, VIZIO_MUTE_ON, VIZIO_SOUND_MODE, VIZIO_VOLUME, ) -from .coordinator import VizioAppsDataUpdateCoordinator - -_LOGGER = logging.getLogger(__name__) +from .coordinator import ( + VizioAppsDataUpdateCoordinator, + VizioConfigEntry, + VizioDeviceCoordinator, +) -SCAN_INTERVAL = timedelta(seconds=30) PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: VizioConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Vizio media player entry.""" - host = config_entry.data[CONF_HOST] - token = config_entry.data.get(CONF_ACCESS_TOKEN) - name = config_entry.data[CONF_NAME] device_class = config_entry.data[CONF_DEVICE_CLASS] # If config entry options not set up, set them up, @@ -105,59 +88,51 @@ async def async_setup_entry( **params, # type: ignore[arg-type] ) - device = VizioAsync( - DEVICE_ID, - host, - name, - auth_token=token, - device_type=VIZIO_DEVICE_CLASSES[device_class], - session=async_get_clientsession(hass, False), - timeout=DEFAULT_TIMEOUT, + entity = VizioDevice( + config_entry, + device_class, + config_entry.runtime_data.device_coordinator, + hass.data.get(DATA_APPS) if device_class == MediaPlayerDeviceClass.TV else None, ) - apps_coordinator = hass.data[DOMAIN].get(CONF_APPS) - - entity = VizioDevice(config_entry, device, name, device_class, apps_coordinator) - - async_add_entities([entity], update_before_add=True) + async_add_entities([entity]) -class VizioDevice(MediaPlayerEntity): +class VizioDevice(CoordinatorEntity[VizioDeviceCoordinator], MediaPlayerEntity): """Media Player implementation which performs REST requests to device.""" _attr_has_entity_name = True _attr_name = None - _received_device_info = False + _current_input: str | None = None + _current_app_config: AppConfig | None = None def __init__( self, - config_entry: ConfigEntry, - device: VizioAsync, - name: str, + config_entry: VizioConfigEntry, device_class: MediaPlayerDeviceClass, + coordinator: VizioDeviceCoordinator, apps_coordinator: VizioAppsDataUpdateCoordinator | None, ) -> None: """Initialize Vizio device.""" + super().__init__(coordinator) + self._config_entry = config_entry self._apps_coordinator = apps_coordinator - - self._volume_step = config_entry.options[CONF_VOLUME_STEP] - self._current_input: str | None = None - self._current_app_config: AppConfig | None = None + self._attr_sound_mode_list = [] 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, [] ) - self._device = device - self._max_volume = float(device.get_max_volume()) - self._attr_assumed_state = True + self._device = coordinator.device + self._max_volume = float(coordinator.device.get_max_volume()) # Entity class attributes that will change with each update (we only include # the ones that are initialized differently from the defaults) - self._attr_sound_mode_list = [] self._attr_supported_features = SUPPORTED_COMMANDS[device_class] # Entity class attributes that will not change @@ -165,11 +140,7 @@ def __init__( assert unique_id self._attr_unique_id = unique_id self._attr_device_class = device_class - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, - manufacturer="VIZIO", - name=name, - ) + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, unique_id)}) def _apps_list(self, apps: list[str]) -> list[str]: """Return process apps list based on configured filters.""" @@ -181,112 +152,72 @@ def _apps_list(self, apps: list[str]) -> list[str]: return apps - async def async_update(self) -> None: - """Retrieve latest state of the device.""" - if ( - is_on := await self._device.get_power_state(log_api_exception=False) - ) is None: - if self._attr_available: - _LOGGER.warning( - "Lost connection to %s", self._config_entry.data[CONF_HOST] - ) - self._attr_available = False - return + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + data = self.coordinator.data - if not self._attr_available: - _LOGGER.warning( - "Restored connection to %s", self._config_entry.data[CONF_HOST] - ) - self._attr_available = True - - if not self._received_device_info: - device_reg = dr.async_get(self.hass) - assert self._config_entry.unique_id - device = device_reg.async_get_device( - identifiers={(DOMAIN, self._config_entry.unique_id)} - ) - if device: - device_reg.async_update_device( - device.id, - model=await self._device.get_model_name(log_api_exception=False), - sw_version=await self._device.get_version(log_api_exception=False), - ) - self._received_device_info = True - - if not is_on: + # Handle device off + if not data.is_on: self._attr_state = MediaPlayerState.OFF self._attr_volume_level = None self._attr_is_volume_muted = None - self._current_input = None + self._attr_sound_mode = None self._attr_app_name = None + self._current_input = None self._current_app_config = None - self._attr_sound_mode = None + super()._handle_coordinator_update() return + # Device is on - apply coordinator data self._attr_state = MediaPlayerState.ON - if audio_settings := await self._device.get_all_settings( - VIZIO_AUDIO_SETTINGS, log_api_exception=False - ): + # Audio settings + if data.audio_settings: self._attr_volume_level = ( - float(audio_settings[VIZIO_VOLUME]) / self._max_volume + float(data.audio_settings[VIZIO_VOLUME]) / self._max_volume ) - if VIZIO_MUTE in audio_settings: + if VIZIO_MUTE in data.audio_settings: self._attr_is_volume_muted = ( - audio_settings[VIZIO_MUTE].lower() == VIZIO_MUTE_ON + data.audio_settings[VIZIO_MUTE].lower() == VIZIO_MUTE_ON ) else: self._attr_is_volume_muted = None - - if VIZIO_SOUND_MODE in audio_settings: + if VIZIO_SOUND_MODE in data.audio_settings: self._attr_supported_features |= ( MediaPlayerEntityFeature.SELECT_SOUND_MODE ) - self._attr_sound_mode = audio_settings[VIZIO_SOUND_MODE] + self._attr_sound_mode = data.audio_settings[VIZIO_SOUND_MODE] if not self._attr_sound_mode_list: - self._attr_sound_mode_list = await self._device.get_setting_options( - VIZIO_AUDIO_SETTINGS, - VIZIO_SOUND_MODE, - log_api_exception=False, - ) + self._attr_sound_mode_list = data.sound_mode_list or [] else: - # Explicitly remove MediaPlayerEntityFeature.SELECT_SOUND_MODE from supported features self._attr_supported_features &= ( ~MediaPlayerEntityFeature.SELECT_SOUND_MODE ) - if input_ := await self._device.get_current_input(log_api_exception=False): - self._current_input = input_ + # Input state + if data.current_input: + self._current_input = data.current_input + if data.input_list: + self._available_inputs = [i.name for i in data.input_list] - # If no inputs returned, end update - if not (inputs := await self._device.get_inputs_list(log_api_exception=False)): - return - - self._available_inputs = [input_.name for input_ in inputs] - - # Return before setting app variables if INPUT_APPS isn't in available inputs - if self._attr_device_class == MediaPlayerDeviceClass.SPEAKER or not any( - app for app in INPUT_APPS if app in self._available_inputs + # App state (TV only) - check if device supports apps + if ( + self._attr_device_class == MediaPlayerDeviceClass.TV + and self._available_inputs + and any(app in self._available_inputs for app in INPUT_APPS) ): - return - - # Create list of available known apps from known app list after - # filtering by CONF_INCLUDE/CONF_EXCLUDE - self._available_apps = self._apps_list( - [app["name"] for app in self._all_apps or ()] - ) - - self._current_app_config = await self._device.get_current_app_config( - log_api_exception=False - ) + all_apps = self._all_apps or () + self._available_apps = self._apps_list([app["name"] for app in all_apps]) + self._current_app_config = data.current_app_config + self._attr_app_name = find_app_name( + self._current_app_config, + [APP_HOME, *all_apps, *self._additional_app_configs], + ) + if self._attr_app_name == NO_APP_RUNNING: + self._attr_app_name = None - self._attr_app_name = find_app_name( - self._current_app_config, - [APP_HOME, *(self._all_apps or ()), *self._additional_app_configs], - ) - - if self._attr_app_name == NO_APP_RUNNING: - self._attr_app_name = None + super()._handle_coordinator_update() def _get_additional_app_names(self) -> list[str]: """Return list of additional apps that were included in configuration.yaml.""" @@ -296,7 +227,7 @@ def _get_additional_app_names(self) -> list[str]: @staticmethod async def _async_send_update_options_signal( - hass: HomeAssistant, config_entry: ConfigEntry + 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 @@ -304,7 +235,7 @@ async def _async_send_update_options_signal( # 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: ConfigEntry) -> None: + 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 @@ -323,6 +254,11 @@ async def async_update_setting( async def async_added_to_hass(self) -> None: """Register callbacks when entity is added.""" + await super().async_added_to_hass() + + # 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( @@ -337,21 +273,17 @@ async def async_added_to_hass(self) -> None: ) ) - if not self._apps_coordinator: + if not (apps_coordinator := self._apps_coordinator): return # Register callback for app list updates if device is a TV @callback def apps_list_update() -> None: """Update list of all apps.""" - if not self._apps_coordinator: - return - self._all_apps = self._apps_coordinator.data + self._all_apps = apps_coordinator.data self.async_write_ha_state() - self.async_on_remove( - self._apps_coordinator.async_add_listener(apps_list_update) - ) + self.async_on_remove(apps_coordinator.async_add_listener(apps_list_update)) @property def source(self) -> str | None: diff --git a/homeassistant/components/vlc_telnet/manifest.json b/homeassistant/components/vlc_telnet/manifest.json index 5041619e84fad7..19dca7a955d660 100644 --- a/homeassistant/components/vlc_telnet/manifest.json +++ b/homeassistant/components/vlc_telnet/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@rodripf", "@MartinHjelmare"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vlc_telnet", + "integration_type": "service", "iot_class": "local_polling", "loggers": ["aiovlc"], "requirements": ["aiovlc==0.5.1"] diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index 6a5d84946699a0..94ac50d0332c67 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -78,6 +78,7 @@ def __init__( data, session, ) + self._session = session # Last resort as no MAC or S/N can be retrieved via API self._id = config_entry.unique_id @@ -135,11 +136,15 @@ async def _async_update_data(self) -> UpdateCoordinatorDataType: _LOGGER.debug("Polling Vodafone Station host: %s", self.api.base_url.host) try: - await self.api.login() + if not self._session.cookie_jar.filter_cookies(self.api.base_url): + _LOGGER.debug( + "Session cookies missing for host %s, re-login", + self.api.base_url.host, + ) + await self.api.login() raw_data_devices = await self.api.get_devices_data() data_sensors = await self.api.get_sensor_data() data_wifi = await self.api.get_wifi_data() - await self.api.logout() except exceptions.CannotAuthenticate as err: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index 25061cfaf5acf9..3121b77049a309 100644 --- a/homeassistant/components/vodafone_station/manifest.json +++ b/homeassistant/components/vodafone_station/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["aiovodafone"], "quality_scale": "platinum", - "requirements": ["aiovodafone==3.1.1"] + "requirements": ["aiovodafone==3.1.3"] } diff --git a/homeassistant/components/vodafone_station/switch.py b/homeassistant/components/vodafone_station/switch.py index c0dd130c3dd567..fd547f446f78e2 100644 --- a/homeassistant/components/vodafone_station/switch.py +++ b/homeassistant/components/vodafone_station/switch.py @@ -104,6 +104,7 @@ async def _set_wifi_status(self, status: bool) -> None: await self.coordinator.api.set_wifi_status( status, self.entity_description.typology, self.entity_description.band ) + await self.coordinator.async_request_refresh() except CannotAuthenticate as err: self.coordinator.config_entry.async_start_reauth(self.hass) raise HomeAssistantError( diff --git a/homeassistant/components/voip/strings.json b/homeassistant/components/voip/strings.json index 417c1900356461..489c16b28ea70c 100644 --- a/homeassistant/components/voip/strings.json +++ b/homeassistant/components/voip/strings.json @@ -22,6 +22,12 @@ "preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]" } }, + "pipeline_n": { + "name": "[%key:component::assist_pipeline::entity::select::pipeline_n::name%]", + "state": { + "preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]" + } + }, "vad_sensitivity": { "name": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::name%]", "state": { diff --git a/homeassistant/components/volumio/manifest.json b/homeassistant/components/volumio/manifest.json index aa4e1d22e2e11d..465a4f4d9af58c 100644 --- a/homeassistant/components/volumio/manifest.json +++ b/homeassistant/components/volumio/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@OnFreund"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/volumio", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["pyvolumio"], "requirements": ["pyvolumio==0.1.5"], diff --git a/homeassistant/components/volvo/__init__.py b/homeassistant/components/volvo/__init__.py index a4f1365274f236..a606ffae0e58f3 100644 --- a/homeassistant/components/volvo/__init__.py +++ b/homeassistant/components/volvo/__init__.py @@ -14,12 +14,14 @@ ConfigEntryError, ConfigEntryNotReady, ) +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, OAuth2Session, async_get_config_entry_implementation, ) +from homeassistant.helpers.typing import ConfigType from .api import VolvoAuth from .const import CONF_VIN, DOMAIN, PLATFORMS @@ -32,6 +34,16 @@ VolvoSlowIntervalCoordinator, VolvoVerySlowIntervalCoordinator, ) +from .services import async_setup_services + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Volvo integration.""" + + await async_setup_services(hass) + return True async def async_setup_entry(hass: HomeAssistant, entry: VolvoConfigEntry) -> bool: diff --git a/homeassistant/components/volvo/coordinator.py b/homeassistant/components/volvo/coordinator.py index 6d4ec873e95ece..db2654da179fda 100644 --- a/homeassistant/components/volvo/coordinator.py +++ b/homeassistant/components/volvo/coordinator.py @@ -186,7 +186,7 @@ def get_api_field(self, api_field: str | None) -> VolvoCarsApiBaseModel | None: async def _async_determine_api_calls( self, ) -> list[Callable[[], Coroutine[Any, Any, Any]]]: - raise NotImplementedError + """Determine which API calls to make for this coordinator.""" class VolvoVerySlowIntervalCoordinator(VolvoBaseCoordinator): diff --git a/homeassistant/components/volvo/icons.json b/homeassistant/components/volvo/icons.json index 9e41dab45ca175..5f888dc890e09e 100644 --- a/homeassistant/components/volvo/icons.json +++ b/homeassistant/components/volvo/icons.json @@ -384,5 +384,10 @@ "default": "mdi:map-marker-distance" } } + }, + "services": { + "get_image_url": { + "service": "mdi:image-multiple-outline" + } } } diff --git a/homeassistant/components/volvo/quality_scale.yaml b/homeassistant/components/volvo/quality_scale.yaml index cdf28b1f958a97..089a0a9b92f37f 100644 --- a/homeassistant/components/volvo/quality_scale.yaml +++ b/homeassistant/components/volvo/quality_scale.yaml @@ -1,19 +1,13 @@ rules: # Bronze - action-setup: - status: exempt - comment: | - The integration does not provide any additional actions. + action-setup: done appropriate-polling: done brands: done common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: - status: exempt - comment: | - The integration does not provide any additional actions. + docs-actions: done docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done @@ -26,10 +20,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: exempt - comment: | - The integration does not provide any additional actions. + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done diff --git a/homeassistant/components/volvo/services.py b/homeassistant/components/volvo/services.py new file mode 100644 index 00000000000000..4f8ff3739ec3d7 --- /dev/null +++ b/homeassistant/components/volvo/services.py @@ -0,0 +1,216 @@ +"""Volvo services.""" + +import asyncio +import logging +from typing import Any +from urllib import parse + +from httpx import AsyncClient, HTTPError, HTTPStatusError +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.httpx_client import get_async_client + +from .const import DOMAIN +from .coordinator import VolvoConfigEntry + +_LOGGER = logging.getLogger(__name__) + +CONF_CONFIG_ENTRY_ID = "entry" +CONF_IMAGE_TYPES = "images" +SERVICE_GET_IMAGE_URL = "get_image_url" +SERVICE_GET_IMAGE_URL_SCHEMA = vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY_ID): str, + vol.Optional(CONF_IMAGE_TYPES): vol.All(cv.ensure_list, [str]), + } +) + +_HEADERS = { + "Accept-Language": "en-GB", + "Sec-Fetch-User": "?1", +} + +_PARAM_IMAGE_ANGLE_MAP = { + "exterior_back": "6", + "exterior_back_left": "5", + "exterior_back_right": "2", + "exterior_front": "3", + "exterior_front_left": "4", + "exterior_front_right": "0", + "exterior_side_left": "7", + "exterior_side_right": "1", +} +_IMAGE_ANGLE_MAP = { + "1": "right", + "3": "front", + "4": "threeQuartersFrontLeft", + "5": "threeQuartersRearLeft", + "6": "rear", + "7": "left", +} + + +async def async_setup_services(hass: HomeAssistant) -> None: + """Set up services.""" + + hass.services.async_register( + DOMAIN, + SERVICE_GET_IMAGE_URL, + _get_image_url, + schema=SERVICE_GET_IMAGE_URL_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + + +async def _get_image_url(call: ServiceCall) -> dict[str, Any]: + entry_id = call.data.get(CONF_CONFIG_ENTRY_ID, "") + requested_images = call.data.get(CONF_IMAGE_TYPES, []) + + entry = _async_get_config_entry(call.hass, entry_id) + image_types = _get_requested_image_types(requested_images) + client = get_async_client(call.hass) + + # Build (type, url) pairs for all requested image types up front + candidates: list[tuple[str, str]] = [] + + for image_type in image_types: + if image_type == "interior": + url = entry.runtime_data.context.vehicle.images.internal_image_url or "" + else: + url = _parse_exterior_image_url( + entry.runtime_data.context.vehicle.images.exterior_image_url, + _PARAM_IMAGE_ANGLE_MAP[image_type], + ) + + candidates.append((image_type, url)) + + # Interior images exist if their URL is populated; exterior images require an HTTP check + async def _check_exists(image_type: str, url: str) -> bool: + if image_type == "interior": + return bool(url) + return await _async_image_exists(client, url) + + # Run checks in parallel + exists_results = await asyncio.gather( + *(_check_exists(image_type, url) for image_type, url in candidates) + ) + + return { + "images": [ + {"type": image_type, "url": url} + for (image_type, url), exists in zip( + candidates, exists_results, strict=True + ) + if exists + ] + } + + +def _async_get_config_entry(hass: HomeAssistant, entry_id: str) -> VolvoConfigEntry: + if not entry_id: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_entry_id", + translation_placeholders={"entry_id": entry_id}, + ) + + if not (entry := hass.config_entries.async_get_entry(entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entry_not_found", + translation_placeholders={"entry_id": entry_id}, + ) + + if entry.domain != DOMAIN: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_entry", + translation_placeholders={"entry_id": entry.entry_id}, + ) + + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entry_not_loaded", + translation_placeholders={"entry_id": entry.entry_id}, + ) + + return entry + + +def _get_requested_image_types(requested_image_types: list[str]) -> list[str]: + allowed_image_types = [*_PARAM_IMAGE_ANGLE_MAP.keys(), "interior"] + + if not requested_image_types: + return allowed_image_types + + image_types: list[str] = [] + + for image_type in requested_image_types: + if image_type in image_types: + continue + + if image_type not in allowed_image_types: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_image_type", + translation_placeholders={"image_type": image_type}, + ) + + image_types.append(image_type) + + return image_types + + +def _parse_exterior_image_url(exterior_url: str, angle: str) -> str: + if not exterior_url: + return "" + + url_parts = parse.urlparse(exterior_url) + + if url_parts.netloc.startswith("wizz"): + if new_angle := _IMAGE_ANGLE_MAP.get(angle): + current_angle = url_parts.path.split("/")[-2] + return exterior_url.replace(current_angle, new_angle) + + return "" + + query = parse.parse_qs(url_parts.query, keep_blank_values=True) + query["angle"] = [angle] + + return url_parts._replace(query=parse.urlencode(query, doseq=True)).geturl() + + +async def _async_image_exists(client: AsyncClient, url: str) -> bool: + if not url: + return False + + try: + async with client.stream( + "GET", url, headers=_HEADERS, timeout=10, follow_redirects=True + ) as response: + response.raise_for_status() + except HTTPStatusError as ex: + status = ex.response.status_code if ex.response is not None else None + + if status in (404, 410): + _LOGGER.debug("Image does not exist: %s", url) + return False + + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="image_error", + translation_placeholders={"url": url}, + ) from ex + except HTTPError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="image_error", + translation_placeholders={"url": url}, + ) from ex + else: + return True diff --git a/homeassistant/components/volvo/services.yaml b/homeassistant/components/volvo/services.yaml new file mode 100644 index 00000000000000..b128eff785afca --- /dev/null +++ b/homeassistant/components/volvo/services.yaml @@ -0,0 +1,24 @@ +get_image_url: + fields: + entry: + required: true + selector: + config_entry: + integration: volvo + images: + required: false + selector: + select: + translation_key: service_param_image + multiple: true + sort: true + options: + - exterior_back + - exterior_back_left + - exterior_back_right + - exterior_front + - exterior_front_left + - exterior_front_right + - exterior_side_left + - exterior_side_right + - interior diff --git a/homeassistant/components/volvo/strings.json b/homeassistant/components/volvo/strings.json index 5360f06634e887..2c41bdb3fd25cd 100644 --- a/homeassistant/components/volvo/strings.json +++ b/homeassistant/components/volvo/strings.json @@ -363,6 +363,24 @@ "command_failure": { "message": "Command {command} failed. Status: {status}. Message: {message}" }, + "entry_not_found": { + "message": "Entry not found: {entry_id}" + }, + "entry_not_loaded": { + "message": "Entry not loaded: {entry_id}" + }, + "image_error": { + "message": "Unable to load vehicle image from: {url}" + }, + "invalid_entry": { + "message": "Invalid entry: {entry_id}" + }, + "invalid_entry_id": { + "message": "Invalid entry ID: {entry_id}" + }, + "invalid_image_type": { + "message": "Invalid image type: {image_type}" + }, "no_vehicle": { "message": "Unable to retrieve vehicle details." }, @@ -375,5 +393,36 @@ "update_failed": { "message": "Unable to update data." } + }, + "selector": { + "service_param_image": { + "options": { + "exterior_back": "Exterior back", + "exterior_back_left": "Exterior back left", + "exterior_back_right": "Exterior back right", + "exterior_front": "Exterior front", + "exterior_front_left": "Exterior front left", + "exterior_front_right": "Exterior front right", + "exterior_side_left": "Exterior side left", + "exterior_side_right": "Exterior side right", + "interior": "Interior" + } + } + }, + "services": { + "get_image_url": { + "description": "Retrieves the URL for one or more vehicle-specific images.", + "fields": { + "entry": { + "description": "The entry to retrieve the vehicle images for.", + "name": "Entry" + }, + "images": { + "description": "The image types to retrieve. Leave empty to get all images.", + "name": "Images" + } + }, + "name": "Get image URL" + } } } diff --git a/homeassistant/components/wallbox/manifest.json b/homeassistant/components/wallbox/manifest.json index cda1f0ced3d559..a326fcba8e5484 100644 --- a/homeassistant/components/wallbox/manifest.json +++ b/homeassistant/components/wallbox/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@hesselonline"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wallbox", + "integration_type": "device", "iot_class": "cloud_polling", "loggers": ["wallbox"], "requirements": ["wallbox==0.9.0"] diff --git a/homeassistant/components/waqi/manifest.json b/homeassistant/components/waqi/manifest.json index cb04bd7d6acba9..4fe09bc7143920 100644 --- a/homeassistant/components/waqi/manifest.json +++ b/homeassistant/components/waqi/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@joostlek"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/waqi", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aiowaqi"], "requirements": ["aiowaqi==3.1.0"] diff --git a/homeassistant/components/waterfurnace/__init__.py b/homeassistant/components/waterfurnace/__init__.py index 85c199075ccb86..aa79ae7efe2170 100644 --- a/homeassistant/components/waterfurnace/__init__.py +++ b/homeassistant/components/waterfurnace/__init__.py @@ -2,10 +2,11 @@ from __future__ import annotations +import asyncio import logging import voluptuous as vol -from waterfurnace.waterfurnace import WaterFurnace, WFCredentialError +from waterfurnace.waterfurnace import WaterFurnace, WFCredentialError, WFException from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform @@ -33,7 +34,7 @@ }, extra=vol.ALLOW_EXTRA, ) -type WaterFurnaceConfigEntry = ConfigEntry[WaterFurnaceCoordinator] +type WaterFurnaceConfigEntry = ConfigEntry[dict[str, WaterFurnaceCoordinator]] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -88,6 +89,27 @@ async def _async_setup(hass: HomeAssistant, config: ConfigType) -> None: ) +async def _async_setup_coordinator( + hass: HomeAssistant, + username: str, + password: str, + device_index: int, + entry: WaterFurnaceConfigEntry, +) -> tuple[str, WaterFurnaceCoordinator]: + """Set up a coordinator for a device.""" + + device_client = WaterFurnace(username, password, device=device_index) + await hass.async_add_executor_job(device_client.login) + coordinator = WaterFurnaceCoordinator(hass, device_client, entry) + await coordinator.async_config_entry_first_refresh() + + if device_client.gwid is None: + raise ConfigEntryNotReady( + f"Invalid GWID for device at index {device_index}: {device_client.gwid}" + ) + return device_client.gwid, coordinator + + async def async_setup_entry( hass: HomeAssistant, entry: WaterFurnaceConfigEntry ) -> bool: @@ -104,14 +126,39 @@ async def async_setup_entry( "Authentication failed. Please update your credentials." ) from err - if not client.gwid: - raise ConfigEntryNotReady( - "Failed to connect to WaterFurnace service: No GWID found for device" - ) - - coordinator = WaterFurnaceCoordinator(hass, client, entry) - entry.runtime_data = coordinator - await coordinator.async_config_entry_first_refresh() + results = await asyncio.gather( + *[ + _async_setup_coordinator(hass, username, password, index, entry) + for index in range(len(client.devices) if client.devices else 0) + ] + ) + entry.runtime_data = dict(results) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True + + +async def async_migrate_entry( + hass: HomeAssistant, entry: WaterFurnaceConfigEntry +) -> bool: + """Migrate old entry.""" + + if entry.version == 1 and entry.minor_version < 2: + # Migrate from gwid-based unique_id to account_id-based unique_id + client = WaterFurnace(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD]) + try: + await hass.async_add_executor_job(client.login) + except WFCredentialError, WFException: + _LOGGER.error("Failed to login during migration to account_id") + return False + + if client.account_id is None: + _LOGGER.error("Account ID is invalid during migration") + return False + + hass.config_entries.async_update_entry( + entry, unique_id=str(client.account_id), minor_version=2 + ) + _LOGGER.info("Migrated config entry unique_id to account_id") + + return True diff --git a/homeassistant/components/waterfurnace/config_flow.py b/homeassistant/components/waterfurnace/config_flow.py index bf5f7f764c57bf..f068558ff595d7 100644 --- a/homeassistant/components/waterfurnace/config_flow.py +++ b/homeassistant/components/waterfurnace/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -27,7 +28,7 @@ class WaterFurnaceConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for WaterFurnace.""" VERSION = 1 - MINOR_VERSION = 1 + MINOR_VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -52,13 +53,14 @@ async def async_step_user( _LOGGER.exception("Unexpected error connecting to WaterFurnace") errors["base"] = "unknown" - gwid = client.gwid - if not gwid: - errors["base"] = "cannot_connect" + if not errors and not client.devices: + errors["base"] = "no_devices" + + if not errors and client.account_id is None: + errors["base"] = "unknown" if not errors: - # Set unique ID based on GWID - await self.async_set_unique_id(gwid) + await self.async_set_unique_id(str(client.account_id)) self._abort_if_unique_id_configured() return self.async_create_entry( @@ -72,6 +74,57 @@ async def async_step_user( errors=errors, ) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle 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.""" + errors: dict[str, str] = {} + + reauth_entry = self._get_reauth_entry() + if user_input is not None: + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + + client = WaterFurnace(username, password) + + try: + await self.hass.async_add_executor_job(client.login) + except WFCredentialError: + errors["base"] = "invalid_auth" + except WFException: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error during reauthentication") + errors["base"] = "unknown" + + if not errors and client.account_id is None: + errors["base"] = "cannot_connect" + + if not errors: + await self.async_set_unique_id(str(client.account_id)) + self._abort_if_unique_id_mismatch(reason="wrong_account") + + return self.async_update_reload_and_abort( + reauth_entry, + title=f"WaterFurnace {username}", + data_updates={**reauth_entry.data, **user_input}, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, + {CONF_USERNAME: reauth_entry.data[CONF_USERNAME]}, + ), + errors=errors, + ) + async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: """Handle import from YAML configuration.""" username = import_data[CONF_USERNAME] @@ -90,13 +143,13 @@ async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResu _LOGGER.exception("Unexpected error importing WaterFurnace configuration") return self.async_abort(reason="unknown") - gwid = client.gwid - if not gwid: - # This likely indicates a server-side change, or an implementation bug - return self.async_abort(reason="cannot_connect") + if not client.devices: + return self.async_abort(reason="no_devices") + + if client.account_id is None: + return self.async_abort(reason="unknown") - # Set unique ID based on GWID - await self.async_set_unique_id(gwid) + await self.async_set_unique_id(str(client.account_id)) self._abort_if_unique_id_configured() return self.async_create_entry( diff --git a/homeassistant/components/waterfurnace/manifest.json b/homeassistant/components/waterfurnace/manifest.json index 2db75d6f363235..614484d5c8b193 100644 --- a/homeassistant/components/waterfurnace/manifest.json +++ b/homeassistant/components/waterfurnace/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["waterfurnace"], "quality_scale": "legacy", - "requirements": ["waterfurnace==1.5.1"] + "requirements": ["waterfurnace==1.6.2"] } diff --git a/homeassistant/components/waterfurnace/sensor.py b/homeassistant/components/waterfurnace/sensor.py index 9e382a43ec07d5..519ea0acea1008 100644 --- a/homeassistant/components/waterfurnace/sensor.py +++ b/homeassistant/components/waterfurnace/sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from homeassistant.components.sensor import ( - ENTITY_ID_FORMAT, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -19,7 +18,6 @@ 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 slugify from . import DOMAIN, WaterFurnaceConfigEntry from .coordinator import WaterFurnaceCoordinator @@ -157,10 +155,10 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Waterfurnace sensors from a config entry.""" - coordinator = config_entry.runtime_data - async_add_entities( - WaterFurnaceSensor(coordinator, description) for description in SENSORS + WaterFurnaceSensor(coordinator, description) + for coordinator in config_entry.runtime_data.values() + for description in SENSORS ) @@ -178,10 +176,6 @@ def __init__( super().__init__(coordinator) self.entity_description = description - # This ensures that the sensors are isolated per waterfurnace unit - self.entity_id = ENTITY_ID_FORMAT.format( - f"wf_{slugify(coordinator.unit)}_{slugify(description.key)}" - ) self._attr_unique_id = f"{coordinator.unit}_{description.key}" device_info = DeviceInfo( diff --git a/homeassistant/components/waterfurnace/strings.json b/homeassistant/components/waterfurnace/strings.json index 647cda2a06acbe..d7d427d9a04cca 100644 --- a/homeassistant/components/waterfurnace/strings.json +++ b/homeassistant/components/waterfurnace/strings.json @@ -4,14 +4,30 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "cannot_connect": "Please verify your credentials.", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "Unexpected error, please try again." + "no_devices": "No devices found on your account.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unknown": "Unexpected error, please try again.", + "wrong_account": "You must reauthenticate with the same WaterFurnace account that was originally configured." }, "error": { "cannot_connect": "Failed to connect to WaterFurnace service", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "no_devices": "No devices found on your account.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "[%key:component::waterfurnace::config::step::user::data_description::password%]", + "username": "[%key:component::waterfurnace::config::step::user::data_description::username%]" + }, + "description": "Please re-enter your WaterFurnace Symphony account credentials.", + "title": "[%key:common::config_flow::title::reauth%]" + }, "user": { "data": { "password": "[%key:common::config_flow::data::password%]", @@ -93,6 +109,10 @@ "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::waterfurnace::issues::deprecated_yaml_import_issue_unknown::title%]" }, + "deprecated_yaml_import_issue_no_devices": { + "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, no devices were found on your account. Please verify your account has devices and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.", + "title": "[%key:component::waterfurnace::issues::deprecated_yaml_import_issue_unknown::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 unknown exception has been encountered. 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": "WaterFurnace YAML configuration import failed" diff --git a/homeassistant/components/watergate/manifest.json b/homeassistant/components/watergate/manifest.json index 25abe1d59b0384..098250a57f1558 100644 --- a/homeassistant/components/watergate/manifest.json +++ b/homeassistant/components/watergate/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["http", "webhook"], "documentation": "https://www.home-assistant.io/integrations/watergate", + "integration_type": "device", "iot_class": "local_push", "quality_scale": "silver", "requirements": ["watergate-local-api==2025.1.0"] diff --git a/homeassistant/components/watts/__init__.py b/homeassistant/components/watts/__init__.py index 18abe77fb4b458..0d4f08741e0461 100644 --- a/homeassistant/components/watts/__init__.py +++ b/homeassistant/components/watts/__init__.py @@ -95,7 +95,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: WattsVisionConfigEntry) ) except config_entry_oauth2_flow.ImplementationUnavailableError as err: raise ConfigEntryNotReady( - "OAuth2 implementation temporarily unavailable" + translation_domain=DOMAIN, + translation_key="oauth_implementation_unavailable", ) from err oauth_session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) @@ -104,10 +105,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: WattsVisionConfigEntry) await oauth_session.async_ensure_token_valid() except ClientResponseError as err: if HTTPStatus.BAD_REQUEST <= err.status < HTTPStatus.INTERNAL_SERVER_ERROR: - raise ConfigEntryAuthFailed("OAuth session not valid") from err - raise ConfigEntryNotReady("Temporary connection error") from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="oauth_session_not_valid", + ) from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="temporary_connection_error", + ) from err except ClientError as err: - raise ConfigEntryNotReady("Network issue during OAuth setup") from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="network_issue", + ) from err session = aiohttp_client.async_get_clientsession(hass) auth = WattsVisionAuth( diff --git a/homeassistant/components/watts/config_flow.py b/homeassistant/components/watts/config_flow.py index c71e67528aa2a2..aa79f24857e080 100644 --- a/homeassistant/components/watts/config_flow.py +++ b/homeassistant/components/watts/config_flow.py @@ -1,11 +1,16 @@ """Config flow for Watts Vision integration.""" +from collections.abc import Mapping import logging from typing import Any from visionpluspython.auth import WattsVisionAuth -from homeassistant.config_entries import ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + ConfigFlowResult, +) from homeassistant.helpers import config_entry_oauth2_flow from .const import DOMAIN, OAUTH2_SCOPES @@ -32,6 +37,37 @@ def extra_authorize_data(self) -> dict[str, Any]: "prompt": "consent", } + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauthentication upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauthentication dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + + return await self.async_step_pick_implementation( + user_input={ + "implementation": self._get_reauth_entry().data["auth_implementation"] + } + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + return await self.async_step_pick_implementation( + user_input={ + "implementation": self._get_reconfigure_entry().data[ + "auth_implementation" + ] + } + ) + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an entry for the OAuth2 flow.""" @@ -42,6 +78,23 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResu return self.async_abort(reason="invalid_token") await self.async_set_unique_id(user_id) + + if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch(reason="account_mismatch") + + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data=data, + ) + + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch(reason="account_mismatch") + + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data=data, + ) + self._abort_if_unique_id_configured() return self.async_create_entry( diff --git a/homeassistant/components/watts/coordinator.py b/homeassistant/components/watts/coordinator.py index 7c95564cecfb63..c24853eb52c74d 100644 --- a/homeassistant/components/watts/coordinator.py +++ b/homeassistant/components/watts/coordinator.py @@ -63,16 +63,16 @@ def __init__( config_entry=config_entry, ) self.client = client - self._last_discovery: datetime | None = None + self.last_discovery: datetime | None = None self.previous_devices: set[str] = set() async def _async_update_data(self) -> dict[str, Device]: """Fetch data and periodic device discovery.""" now = datetime.now() - is_first_refresh = self._last_discovery is None + is_first_refresh = self.last_discovery is None discovery_interval_elapsed = ( - self._last_discovery is not None - and now - self._last_discovery + self.last_discovery is not None + and now - self.last_discovery >= timedelta(minutes=DISCOVERY_INTERVAL_MINUTES) ) @@ -80,7 +80,10 @@ async def _async_update_data(self) -> dict[str, Device]: try: devices_list = await self.client.discover_devices() except WattsVisionAuthError as err: - raise ConfigEntryAuthFailed("Authentication failed") from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_failed", + ) from err except ( WattsVisionConnectionError, WattsVisionTimeoutError, @@ -91,12 +94,15 @@ async def _async_update_data(self) -> dict[str, Device]: ValueError, ) as err: if is_first_refresh: - raise ConfigEntryNotReady("Failed to discover devices") from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_discover_devices", + ) from err _LOGGER.warning( "Periodic discovery failed: %s, falling back to update", err ) else: - self._last_discovery = now + self.last_discovery = now devices = {device.device_id: device for device in devices_list} current_devices = set(devices.keys()) @@ -114,7 +120,10 @@ async def _async_update_data(self) -> dict[str, Device]: try: devices = await self.client.get_devices_report(device_ids) except WattsVisionAuthError as err: - raise ConfigEntryAuthFailed("Authentication failed") from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_failed", + ) from err except ( WattsVisionConnectionError, WattsVisionTimeoutError, @@ -124,7 +133,10 @@ async def _async_update_data(self) -> dict[str, Device]: TimeoutError, ValueError, ) as err: - raise UpdateFailed("Failed to update devices") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="failed_to_update_devices", + ) from err _LOGGER.debug("Updated %d devices", len(devices)) return devices @@ -172,7 +184,7 @@ def __init__( self.client = client self.device_id = device_id self.hub_coordinator = hub_coordinator - self._fast_polling_until: datetime | None = None + self.fast_polling_until: datetime | None = None # Listen to hub coordinator updates self.unsubscribe_hub_listener = hub_coordinator.async_add_listener( @@ -187,8 +199,8 @@ def _handle_hub_update(self) -> None: async def _async_update_data(self) -> WattsVisionDeviceData: """Refresh specific device.""" - if self._fast_polling_until and datetime.now() > self._fast_polling_until: - self._fast_polling_until = None + if self.fast_polling_until and datetime.now() > self.fast_polling_until: + self.fast_polling_until = None self.update_interval = None _LOGGER.debug( "Device %s: Fast polling period ended, returning to manual refresh", @@ -207,17 +219,25 @@ async def _async_update_data(self) -> WattsVisionDeviceData: TimeoutError, ValueError, ) as err: - raise UpdateFailed(f"Failed to refresh device {self.device_id}") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="failed_to_refresh_device", + translation_placeholders={"device_id": self.device_id}, + ) from err if not device: - raise UpdateFailed(f"Device {self.device_id} not found") + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={"device_id": self.device_id}, + ) _LOGGER.debug("Refreshed device %s", self.device_id) return WattsVisionDeviceData(device=device) def trigger_fast_polling(self, duration: int = 60) -> None: """Activate fast polling for a specified duration after a command.""" - self._fast_polling_until = datetime.now() + timedelta(seconds=duration) + self.fast_polling_until = datetime.now() + timedelta(seconds=duration) self.update_interval = timedelta(seconds=FAST_POLLING_INTERVAL_SECONDS) _LOGGER.debug( "Device %s: Activated fast polling for %d seconds", self.device_id, duration diff --git a/homeassistant/components/watts/diagnostics.py b/homeassistant/components/watts/diagnostics.py new file mode 100644 index 00000000000000..33912dc71a844f --- /dev/null +++ b/homeassistant/components/watts/diagnostics.py @@ -0,0 +1,69 @@ +"""Diagnostics support for Watts Vision +.""" + +from __future__ import annotations + +import dataclasses +from datetime import datetime +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant + +from . import WattsVisionConfigEntry + +TO_REDACT = ("refresh_token", "id_token", "profile_info", "unique_id") + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: WattsVisionConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + runtime_data = entry.runtime_data + hub_coordinator = runtime_data.hub_coordinator + device_coordinators = runtime_data.device_coordinators + now = datetime.now() + + return async_redact_data( + { + "entry": entry.as_dict(), + "hub_coordinator": { + "last_update_success": hub_coordinator.last_update_success, + "last_exception": ( + str(hub_coordinator.last_exception) + if hub_coordinator.last_exception + else None + ), + "last_discovery": ( + hub_coordinator.last_discovery.isoformat() + if hub_coordinator.last_discovery + else None + ), + "total_devices": len(hub_coordinator.data), + "supported_devices": len(device_coordinators), + }, + "hub_data": { + device_id: dataclasses.asdict(device) + for device_id, device in hub_coordinator.data.items() + }, + "devices": { + device_id: { + "device": dataclasses.asdict(coordinator.data.device), + "last_update_success": coordinator.last_update_success, + "fast_polling_active": ( + coordinator.fast_polling_until is not None + and coordinator.fast_polling_until > now + ), + "fast_polling_until": ( + coordinator.fast_polling_until.isoformat() + if coordinator.fast_polling_until is not None + and coordinator.fast_polling_until > now + else None + ), + } + for device_id, coordinator in device_coordinators.items() + }, + }, + {CONF_ACCESS_TOKEN, *TO_REDACT}, + ) diff --git a/homeassistant/components/watts/manifest.json b/homeassistant/components/watts/manifest.json index 71fac5e6a69350..65d4a1323d953b 100644 --- a/homeassistant/components/watts/manifest.json +++ b/homeassistant/components/watts/manifest.json @@ -5,7 +5,8 @@ "config_flow": true, "dependencies": ["application_credentials", "cloud"], "documentation": "https://www.home-assistant.io/integrations/watts", + "integration_type": "hub", "iot_class": "cloud_polling", - "quality_scale": "bronze", + "quality_scale": "platinum", "requirements": ["visionpluspython==1.0.2"] } diff --git a/homeassistant/components/watts/quality_scale.yaml b/homeassistant/components/watts/quality_scale.yaml index 152dcbbd3f5c53..c42cee4a798ae6 100644 --- a/homeassistant/components/watts/quality_scale.yaml +++ b/homeassistant/components/watts/quality_scale.yaml @@ -30,12 +30,12 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: Integration does not support discovery. @@ -56,11 +56,11 @@ rules: entity-translations: status: exempt comment: No entity required translations. - exception-translations: todo + exception-translations: done icon-translations: status: exempt comment: Thermostat entities use standard HA Climate entity. - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: No actionable repair scenarios, auth issues are handled by reauthentication flow. diff --git a/homeassistant/components/watts/strings.json b/homeassistant/components/watts/strings.json index d7a38341abe148..9f1c761d8f7edb 100644 --- a/homeassistant/components/watts/strings.json +++ b/homeassistant/components/watts/strings.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "account_mismatch": "The authenticated account does not match the configured account", "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%]", @@ -12,6 +13,8 @@ "oauth_implementation_unavailable": "[%key:common::config_flow::abort::oauth2_implementation_unavailable%]", "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%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" }, "create_entry": { @@ -20,18 +23,49 @@ "step": { "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "description": "The Watts Vision + integration needs to re-authenticate your account", + "title": "[%key:common::config_flow::title::reauth%]" } } }, "exceptions": { + "authentication_failed": { + "message": "Authentication failed" + }, + "device_not_found": { + "message": "Device {device_id} not found" + }, + "failed_to_discover_devices": { + "message": "Failed to discover devices" + }, + "failed_to_refresh_device": { + "message": "Failed to refresh device {device_id}" + }, + "failed_to_update_devices": { + "message": "Failed to update devices" + }, + "network_issue": { + "message": "Network issue during OAuth setup" + }, + "oauth_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + }, + "oauth_session_not_valid": { + "message": "OAuth session not valid" + }, "set_hvac_mode_error": { - "message": "An error occurred while setting the HVAC mode." + "message": "An error occurred while setting the HVAC mode" }, "set_switch_state_error": { - "message": "An error occurred while setting the switch state." + "message": "An error occurred while setting the switch state" }, "set_temperature_error": { - "message": "An error occurred while setting the temperature." + "message": "An error occurred while setting the temperature" + }, + "temporary_connection_error": { + "message": "Temporary connection error" } } } diff --git a/homeassistant/components/watttime/__init__.py b/homeassistant/components/watttime/__init__.py index ed2bdd4ebac88c..6e67994b11a2a2 100644 --- a/homeassistant/components/watttime/__init__.py +++ b/homeassistant/components/watttime/__init__.py @@ -2,28 +2,17 @@ from __future__ import annotations -from datetime import timedelta - from aiowatttime import Client -from aiowatttime.emissions import RealTimeEmissionsResponseType from aiowatttime.errors import InvalidCredentialsError, WattTimeError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_PASSWORD, - CONF_USERNAME, - Platform, -) +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 homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER - -DEFAULT_UPDATE_INTERVAL = timedelta(minutes=5) +from .coordinator import WattTimeCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -42,27 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: LOGGER.error("Error while authenticating with WattTime: %s", err) return False - async def async_update_data() -> RealTimeEmissionsResponseType: - """Get the latest realtime emissions data.""" - try: - return await client.emissions.async_get_realtime_emissions( - entry.data[CONF_LATITUDE], entry.data[CONF_LONGITUDE] - ) - except InvalidCredentialsError as err: - raise ConfigEntryAuthFailed("Invalid username/password") from err - except WattTimeError as err: - raise UpdateFailed( - f"Error while requesting data from WattTime: {err}" - ) from err - - coordinator = DataUpdateCoordinator( - hass, - LOGGER, - config_entry=entry, - name=entry.title, - update_interval=DEFAULT_UPDATE_INTERVAL, - update_method=async_update_data, - ) + coordinator = WattTimeCoordinator(hass, entry, client) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/watttime/coordinator.py b/homeassistant/components/watttime/coordinator.py new file mode 100644 index 00000000000000..a726555db538ad --- /dev/null +++ b/homeassistant/components/watttime/coordinator.py @@ -0,0 +1,55 @@ +"""Coordinator for the WattTime integration.""" + +from __future__ import annotations + +from datetime import timedelta + +from aiowatttime import Client +from aiowatttime.emissions import RealTimeEmissionsResponseType +from aiowatttime.errors import InvalidCredentialsError, WattTimeError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + +DEFAULT_UPDATE_INTERVAL = timedelta(minutes=5) + + +class WattTimeCoordinator(DataUpdateCoordinator[RealTimeEmissionsResponseType]): + """Coordinator for WattTime data updates.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + client: Client, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=DEFAULT_UPDATE_INTERVAL, + ) + self.client = client + + async def _async_update_data(self) -> RealTimeEmissionsResponseType: + """Get the latest realtime emissions data.""" + try: + return await self.client.emissions.async_get_realtime_emissions( + self.config_entry.data[CONF_LATITUDE], + self.config_entry.data[CONF_LONGITUDE], + ) + except InvalidCredentialsError as err: + raise ConfigEntryAuthFailed("Invalid username/password") from err + except WattTimeError as err: + raise UpdateFailed( + f"Error while requesting data from WattTime: {err}" + ) from err diff --git a/homeassistant/components/watttime/diagnostics.py b/homeassistant/components/watttime/diagnostics.py index adedcd13835765..b779b2759d1dd0 100644 --- a/homeassistant/components/watttime/diagnostics.py +++ b/homeassistant/components/watttime/diagnostics.py @@ -14,9 +14,9 @@ CONF_USERNAME, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import CONF_BALANCING_AUTHORITY, CONF_BALANCING_AUTHORITY_ABBREV, DOMAIN +from .coordinator import WattTimeCoordinator CONF_TITLE = "title" @@ -37,7 +37,7 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: WattTimeCoordinator = hass.data[DOMAIN][entry.entry_id] return async_redact_data( { diff --git a/homeassistant/components/watttime/sensor.py b/homeassistant/components/watttime/sensor.py index d3aa9d8f895e02..23824a1369a029 100644 --- a/homeassistant/components/watttime/sensor.py +++ b/homeassistant/components/watttime/sensor.py @@ -22,12 +22,10 @@ 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, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_BALANCING_AUTHORITY, CONF_BALANCING_AUTHORITY_ABBREV, DOMAIN +from .coordinator import WattTimeCoordinator ATTR_BALANCING_AUTHORITY = "balancing_authority" @@ -67,14 +65,14 @@ async def async_setup_entry( ) -class RealtimeEmissionsSensor(CoordinatorEntity, SensorEntity): +class RealtimeEmissionsSensor(CoordinatorEntity[WattTimeCoordinator], SensorEntity): """Define a realtime emissions sensor.""" _attr_has_entity_name = True def __init__( self, - coordinator: DataUpdateCoordinator, + coordinator: WattTimeCoordinator, entry: ConfigEntry, description: SensorEntityDescription, ) -> None: diff --git a/homeassistant/components/waze_travel_time/__init__.py b/homeassistant/components/waze_travel_time/__init__.py index 093a35177a00f8..4dd901e8bdcc32 100644 --- a/homeassistant/components/waze_travel_time/__init__.py +++ b/homeassistant/components/waze_travel_time/__init__.py @@ -1,6 +1,7 @@ """The waze_travel_time component.""" import asyncio +from datetime import timedelta import logging from pywaze.route_calculator import WazeRouteCalculator @@ -18,6 +19,8 @@ from homeassistant.helpers.location import find_coordinates from homeassistant.helpers.selector import ( BooleanSelector, + DurationSelector, + DurationSelectorConfig, SelectSelector, SelectSelectorConfig, SelectSelectorMode, @@ -35,9 +38,11 @@ CONF_INCL_FILTER, CONF_ORIGIN, CONF_REALTIME, + CONF_TIME_DELTA, CONF_UNITS, CONF_VEHICLE_TYPE, DEFAULT_FILTER, + DEFAULT_TIME_DELTA, DEFAULT_VEHICLE_TYPE, DOMAIN, METRIC_UNITS, @@ -95,6 +100,9 @@ multiple=True, ), ), + vol.Optional(CONF_TIME_DELTA): DurationSelector( + DurationSelectorConfig(allow_negative=True, enable_second=False) + ), } ) @@ -130,6 +138,13 @@ 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] + time_delta = int( + timedelta( + **service.data.get(CONF_TIME_DELTA, DEFAULT_TIME_DELTA) + ).total_seconds() + / 60 + ) + response = await async_get_travel_times( client=client, origin=origin, @@ -142,6 +157,7 @@ async def async_get_travel_times_service(service: ServiceCall) -> ServiceRespons units=service.data[CONF_UNITS], incl_filters=service.data.get(CONF_INCL_FILTER, DEFAULT_FILTER), excl_filters=service.data.get(CONF_EXCL_FILTER, DEFAULT_FILTER), + time_delta=time_delta, ) return {"routes": [vars(route) for route in response]} @@ -184,4 +200,22 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry.version, config_entry.minor_version, ) + + if config_entry.version == 2 and config_entry.minor_version == 1: + _LOGGER.debug( + "Migrating from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + options = dict(config_entry.options) + options[CONF_TIME_DELTA] = DEFAULT_TIME_DELTA + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=2 + ) + _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 6ab6a4b121c8ac..1b97bed0a8847d 100644 --- a/homeassistant/components/waze_travel_time/config_flow.py +++ b/homeassistant/components/waze_travel_time/config_flow.py @@ -17,6 +17,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.selector import ( BooleanSelector, + DurationSelector, + DurationSelectorConfig, SelectSelector, SelectSelectorConfig, SelectSelectorMode, @@ -35,11 +37,13 @@ CONF_INCL_FILTER, CONF_ORIGIN, CONF_REALTIME, + CONF_TIME_DELTA, CONF_UNITS, CONF_VEHICLE_TYPE, DEFAULT_FILTER, DEFAULT_NAME, DEFAULT_OPTIONS, + DEFAULT_TIME_DELTA, DOMAIN, IMPERIAL_UNITS, REGIONS, @@ -82,6 +86,12 @@ vol.Optional(CONF_AVOID_TOLL_ROADS): BooleanSelector(), vol.Optional(CONF_AVOID_SUBSCRIPTION_ROADS): BooleanSelector(), vol.Optional(CONF_AVOID_FERRIES): BooleanSelector(), + vol.Optional(CONF_TIME_DELTA): DurationSelector( + DurationSelectorConfig( + allow_negative=True, + enable_second=False, + ) + ), } ) @@ -102,7 +112,9 @@ ) -def default_options(hass: HomeAssistant) -> dict[str, str | bool | list[str]]: +def default_options( + hass: HomeAssistant, +) -> dict[str, str | bool | list[str] | dict[str, int]]: """Get the default options.""" defaults = DEFAULT_OPTIONS.copy() if hass.config.units is US_CUSTOMARY_SYSTEM: @@ -120,6 +132,8 @@ async def async_step_init(self, user_input=None) -> ConfigFlowResult: user_input[CONF_INCL_FILTER] = DEFAULT_FILTER if user_input.get(CONF_EXCL_FILTER) is None: user_input[CONF_EXCL_FILTER] = DEFAULT_FILTER + if user_input.get(CONF_TIME_DELTA) is None: + user_input[CONF_TIME_DELTA] = DEFAULT_TIME_DELTA return self.async_create_entry( title="", data=user_input, @@ -137,6 +151,7 @@ class WazeConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Waze Travel Time.""" VERSION = 2 + MINOR_VERSION = 2 @staticmethod @callback diff --git a/homeassistant/components/waze_travel_time/const.py b/homeassistant/components/waze_travel_time/const.py index 7c77f43574d670..894c8a6c0a8280 100644 --- a/homeassistant/components/waze_travel_time/const.py +++ b/homeassistant/components/waze_travel_time/const.py @@ -15,8 +15,10 @@ CONF_AVOID_TOLL_ROADS = "avoid_toll_roads" CONF_AVOID_SUBSCRIPTION_ROADS = "avoid_subscription_roads" CONF_AVOID_FERRIES = "avoid_ferries" +CONF_TIME_DELTA = "time_delta" DEFAULT_NAME = "Waze Travel Time" +DEFAULT_TIME_DELTA = {"minutes": 0} DEFAULT_REALTIME = True DEFAULT_VEHICLE_TYPE = "car" DEFAULT_AVOID_TOLL_ROADS = False @@ -31,7 +33,7 @@ REGIONS = ["us", "na", "eu", "il", "au"] VEHICLE_TYPES = ["car", "taxi", "motorcycle"] -DEFAULT_OPTIONS: dict[str, str | bool | list[str]] = { +DEFAULT_OPTIONS: dict[str, str | bool | list[str] | dict[str, int]] = { CONF_REALTIME: DEFAULT_REALTIME, CONF_VEHICLE_TYPE: DEFAULT_VEHICLE_TYPE, CONF_UNITS: METRIC_UNITS, @@ -40,4 +42,5 @@ CONF_AVOID_TOLL_ROADS: DEFAULT_AVOID_TOLL_ROADS, CONF_INCL_FILTER: DEFAULT_FILTER, CONF_EXCL_FILTER: DEFAULT_FILTER, + CONF_TIME_DELTA: DEFAULT_TIME_DELTA, } diff --git a/homeassistant/components/waze_travel_time/coordinator.py b/homeassistant/components/waze_travel_time/coordinator.py index 23dfea86ed2c3e..0cf4f4ef78359c 100644 --- a/homeassistant/components/waze_travel_time/coordinator.py +++ b/homeassistant/components/waze_travel_time/coordinator.py @@ -25,6 +25,7 @@ CONF_INCL_FILTER, CONF_ORIGIN, CONF_REALTIME, + CONF_TIME_DELTA, CONF_UNITS, CONF_VEHICLE_TYPE, DOMAIN, @@ -51,6 +52,7 @@ async def async_get_travel_times( units: Literal["metric", "imperial"] = "metric", incl_filters: Collection[str] | None = None, excl_filters: Collection[str] | None = None, + time_delta: int = 0, ) -> list[CalcRoutesResponse]: """Get all available routes.""" @@ -74,6 +76,7 @@ async def async_get_travel_times( avoid_ferries=avoid_ferries, real_time=realtime, alternatives=3, + time_delta=time_delta, ) if len(routes) < 1: @@ -204,6 +207,11 @@ async def _async_update_data(self) -> WazeTravelTimeData: CONF_AVOID_SUBSCRIPTION_ROADS ] avoid_ferries = self.config_entry.options[CONF_AVOID_FERRIES] + time_delta = int( + timedelta(**self.config_entry.options[CONF_TIME_DELTA]).total_seconds() + / 60 + ) + routes = await async_get_travel_times( self.client, origin_coordinates, @@ -216,6 +224,7 @@ async def _async_update_data(self) -> WazeTravelTimeData: self.config_entry.options[CONF_UNITS], incl_filter, excl_filter, + time_delta, ) if len(routes) < 1: travel_data = WazeTravelTimeData( diff --git a/homeassistant/components/waze_travel_time/manifest.json b/homeassistant/components/waze_travel_time/manifest.json index 9640a8d407dadf..8e721179650366 100644 --- a/homeassistant/components/waze_travel_time/manifest.json +++ b/homeassistant/components/waze_travel_time/manifest.json @@ -4,7 +4,8 @@ "codeowners": ["@eifinger"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/waze_travel_time", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pywaze", "homeassistant.helpers.location"], - "requirements": ["pywaze==1.1.1"] + "requirements": ["pywaze==1.2.0"] } diff --git a/homeassistant/components/waze_travel_time/services.yaml b/homeassistant/components/waze_travel_time/services.yaml index fd5f2e9adea6a7..6d1faf2904510a 100644 --- a/homeassistant/components/waze_travel_time/services.yaml +++ b/homeassistant/components/waze_travel_time/services.yaml @@ -65,3 +65,7 @@ get_travel_times: selector: text: multiple: true + time_delta: + required: false + selector: + duration: diff --git a/homeassistant/components/waze_travel_time/strings.json b/homeassistant/components/waze_travel_time/strings.json index dcbf2edef6b8ce..55bb7cf995b163 100644 --- a/homeassistant/components/waze_travel_time/strings.json +++ b/homeassistant/components/waze_travel_time/strings.json @@ -29,6 +29,7 @@ "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?", + "time_delta": "Time delta", "units": "Units", "vehicle_type": "Vehicle type" }, @@ -100,6 +101,10 @@ "description": "The region. Controls which Waze server is used.", "name": "[%key:component::waze_travel_time::config::step::user::data::region%]" }, + "time_delta": { + "description": "Time offset from now to calculate the route for. Positive values are in the future, negative values are in the past.", + "name": "Time delta" + }, "units": { "description": "Which unit system to use.", "name": "[%key:component::waze_travel_time::options::step::init::data::units%]" diff --git a/homeassistant/components/weatherflow/manifest.json b/homeassistant/components/weatherflow/manifest.json index b0c855037b307a..050ee6f9dbe5a2 100644 --- a/homeassistant/components/weatherflow/manifest.json +++ b/homeassistant/components/weatherflow/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyweatherflowudp"], - "requirements": ["pyweatherflowudp==1.5.0"] + "requirements": ["pyweatherflowudp==1.5.2"] } diff --git a/homeassistant/components/weatherflow_cloud/manifest.json b/homeassistant/components/weatherflow_cloud/manifest.json index d39e373312df7e..38c73969bff9fd 100644 --- a/homeassistant/components/weatherflow_cloud/manifest.json +++ b/homeassistant/components/weatherflow_cloud/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@jeeftor"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/weatherflow_cloud", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["weatherflow4py"], "requirements": ["weatherflow4py==1.4.1"] diff --git a/homeassistant/components/weatherkit/coordinator.py b/homeassistant/components/weatherkit/coordinator.py index 6c7119d6fb0377..fd790ee230f522 100644 --- a/homeassistant/components/weatherkit/coordinator.py +++ b/homeassistant/components/weatherkit/coordinator.py @@ -11,6 +11,7 @@ from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util from .const import DOMAIN, LOGGER @@ -22,6 +23,8 @@ STALE_DATA_THRESHOLD = timedelta(hours=1) +HOURLY_FORECAST_DURATION = timedelta(days=7) + class WeatherKitDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching data from the API.""" @@ -67,10 +70,13 @@ async def _async_update_data(self): if not self.supported_data_sets: await self.update_supported_data_sets() + dt_now = dt_util.utcnow() updated_data = await self.client.get_weather_data( self.config_entry.data[CONF_LATITUDE], self.config_entry.data[CONF_LONGITUDE], self.supported_data_sets, + hourly_start=dt_now, + hourly_end=dt_now + HOURLY_FORECAST_DURATION, ) except WeatherKitApiClientError as exception: if self.data is None or ( diff --git a/homeassistant/components/weatherkit/manifest.json b/homeassistant/components/weatherkit/manifest.json index f86745f330fc3c..e7f5b2ed1c4daf 100644 --- a/homeassistant/components/weatherkit/manifest.json +++ b/homeassistant/components/weatherkit/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@tjhorner"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/weatherkit", + "integration_type": "service", "iot_class": "cloud_polling", "requirements": ["apple_weatherkit==1.1.3"] } diff --git a/homeassistant/components/webdav/backup.py b/homeassistant/components/webdav/backup.py index a9afb5fe930c23..10462dc9147a46 100644 --- a/homeassistant/components/webdav/backup.py +++ b/homeassistant/components/webdav/backup.py @@ -17,6 +17,7 @@ BackupAgent, BackupAgentError, BackupNotFound, + OnProgressCallback, suggested_filename, ) from homeassistant.core import HomeAssistant, callback @@ -140,6 +141,7 @@ async def async_upload_backup( *, open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], backup: AgentBackup, + on_progress: OnProgressCallback, **kwargs: Any, ) -> None: """Upload a backup. @@ -154,6 +156,7 @@ async def async_upload_backup( f"{self._backup_path}/{filename_tar}", timeout=BACKUP_TIMEOUT, content_length=backup.size, + progress=lambda current, total: on_progress(bytes_uploaded=current), ) _LOGGER.debug( @@ -222,8 +225,10 @@ async def _list_cached_metadata_files(self) -> dict[str, AgentBackup]: async def _download_metadata(path: str) -> AgentBackup: """Download metadata file.""" iterator = await self._client.download_iter(path) - metadata = await anext(iterator) - return AgentBackup.from_dict(json_loads_object(metadata)) + metadata_bytes = bytearray() + async for chunk in iterator: + metadata_bytes.extend(chunk) + return AgentBackup.from_dict(json_loads_object(metadata_bytes)) async def _list_metadata_files() -> dict[str, AgentBackup]: """List metadata files.""" diff --git a/homeassistant/components/webdav/manifest.json b/homeassistant/components/webdav/manifest.json index 29559bfc186a78..91ae2e8a127433 100644 --- a/homeassistant/components/webdav/manifest.json +++ b/homeassistant/components/webdav/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aiowebdav2"], "quality_scale": "bronze", - "requirements": ["aiowebdav2==0.5.0"] + "requirements": ["aiowebdav2==0.6.2"] } diff --git a/homeassistant/components/webdav/quality_scale.yaml b/homeassistant/components/webdav/quality_scale.yaml index 560626fda7e271..59a98e0e747daa 100644 --- a/homeassistant/components/webdav/quality_scale.yaml +++ b/homeassistant/components/webdav/quality_scale.yaml @@ -129,10 +129,7 @@ rules: status: exempt comment: | This integration does not have entities. - reconfiguration-flow: - status: exempt - comment: | - Nothing to reconfigure. + reconfiguration-flow: todo repair-issues: todo stale-devices: status: exempt diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 5643f07e7ee0f1..e083a8253b14a6 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -332,6 +332,7 @@ async def handle_call_service( connection.logger.error( "Error during service call to %s.%s: %s", msg["domain"], msg["service"], err ) + connection.logger.debug("", exc_info=True) connection.send_error( msg["id"], const.ERR_HOME_ASSISTANT_ERROR, diff --git a/homeassistant/components/weheat/const.py b/homeassistant/components/weheat/const.py index cd521afd2eacac..20df56bafd6efa 100644 --- a/homeassistant/components/weheat/const.py +++ b/homeassistant/components/weheat/const.py @@ -13,7 +13,7 @@ OAUTH2_TOKEN = ( "https://auth.weheat.nl/auth/realms/Weheat/protocol/openid-connect/token/" ) -API_URL = "https://api.weheat.nl" +API_URL = "https://api.weheat.nl/third_party" OAUTH2_SCOPES = ["openid", "offline_access"] diff --git a/homeassistant/components/weheat/icons.json b/homeassistant/components/weheat/icons.json index e8eb5bb8dd9a3f..9606cbdf6fba3c 100644 --- a/homeassistant/components/weheat/icons.json +++ b/homeassistant/components/weheat/icons.json @@ -39,6 +39,33 @@ "electricity_used": { "default": "mdi:flash" }, + "electricity_used_cooling": { + "default": "mdi:flash" + }, + "electricity_used_defrost": { + "default": "mdi:flash" + }, + "electricity_used_dhw": { + "default": "mdi:flash" + }, + "electricity_used_heating": { + "default": "mdi:flash" + }, + "energy_output": { + "default": "mdi:flash" + }, + "energy_output_cooling": { + "default": "mdi:snowflake" + }, + "energy_output_defrost": { + "default": "mdi:snowflake" + }, + "energy_output_dhw": { + "default": "mdi:heat-wave" + }, + "energy_output_heating": { + "default": "mdi:heat-wave" + }, "heat_pump_state": { "default": "mdi:state-machine" }, diff --git a/homeassistant/components/weheat/manifest.json b/homeassistant/components/weheat/manifest.json index 83a933654ec551..304494fcc3702e 100644 --- a/homeassistant/components/weheat/manifest.json +++ b/homeassistant/components/weheat/manifest.json @@ -1,10 +1,11 @@ { "domain": "weheat", "name": "Weheat", - "codeowners": ["@jesperraemaekers"], + "codeowners": ["@barryvdh"], "config_flow": true, "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/weheat", + "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["weheat==2026.1.25"] + "requirements": ["weheat==2026.2.28"] } diff --git a/homeassistant/components/weheat/sensor.py b/homeassistant/components/weheat/sensor.py index 0e6170fc33d6e8..960749a1aa127c 100644 --- a/homeassistant/components/weheat/sensor.py +++ b/homeassistant/components/weheat/sensor.py @@ -221,6 +221,73 @@ class WeHeatSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.TOTAL_INCREASING, value_fn=lambda status: status.energy_output, ), + WeHeatSensorEntityDescription( + translation_key="electricity_used_heating", + key="electricity_used_heating", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda status: status.energy_in_heating, + ), + WeHeatSensorEntityDescription( + translation_key="electricity_used_cooling", + key="electricity_used_cooling", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda status: status.energy_in_cooling, + ), + WeHeatSensorEntityDescription( + translation_key="electricity_used_defrost", + key="electricity_used_defrost", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda status: status.energy_in_defrost, + ), + WeHeatSensorEntityDescription( + translation_key="energy_output_heating", + key="energy_output_heating", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda status: status.energy_out_heating, + ), + WeHeatSensorEntityDescription( + translation_key="energy_output_cooling", + key="energy_output_cooling", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + value_fn=lambda status: status.energy_out_cooling, + ), + WeHeatSensorEntityDescription( + translation_key="energy_output_defrost", + key="energy_output_defrost", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + value_fn=lambda status: status.energy_out_defrost, + ), +] + +DHW_ENERGY_SENSORS = [ + WeHeatSensorEntityDescription( + translation_key="electricity_used_dhw", + key="electricity_used_dhw", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda status: status.energy_in_dhw, + ), + WeHeatSensorEntityDescription( + translation_key="energy_output_dhw", + key="energy_output_dhw", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda status: status.energy_out_dhw, + ), ] @@ -253,6 +320,16 @@ async def async_setup_entry( if entity_description.value_fn(weheatdata.data_coordinator.data) is not None ) + entities.extend( + WeheatHeatPumpSensor( + weheatdata.heat_pump_info, + weheatdata.energy_coordinator, + entity_description, + ) + for entity_description in DHW_ENERGY_SENSORS + if entity_description.value_fn(weheatdata.energy_coordinator.data) + is not None + ) entities.extend( WeheatHeatPumpSensor( weheatdata.heat_pump_info, diff --git a/homeassistant/components/weheat/strings.json b/homeassistant/components/weheat/strings.json index eb60bcbc737117..f98d1ab086dd8b 100644 --- a/homeassistant/components/weheat/strings.json +++ b/homeassistant/components/weheat/strings.json @@ -84,9 +84,33 @@ "electricity_used": { "name": "Electricity used" }, + "electricity_used_cooling": { + "name": "Electricity used cooling" + }, + "electricity_used_defrost": { + "name": "Electricity used defrost" + }, + "electricity_used_dhw": { + "name": "Electricity used DHW" + }, + "electricity_used_heating": { + "name": "Electricity used heating" + }, "energy_output": { "name": "Total energy output" }, + "energy_output_cooling": { + "name": "Energy output cooling" + }, + "energy_output_defrost": { + "name": "Energy output defrost" + }, + "energy_output_dhw": { + "name": "Energy output DHW" + }, + "energy_output_heating": { + "name": "Energy output heating" + }, "heat_pump_state": { "state": { "cooling": "Cooling", diff --git a/homeassistant/components/wemo/coordinator.py b/homeassistant/components/wemo/coordinator.py index cb3c8a558b63f2..129c00b18cf1cd 100644 --- a/homeassistant/components/wemo/coordinator.py +++ b/homeassistant/components/wemo/coordinator.py @@ -7,7 +7,7 @@ from datetime import timedelta from functools import partial import logging -from typing import TYPE_CHECKING, Literal +from typing import Literal from pywemo import Insight, LongPressMixin, WeMoDevice from pywemo.exceptions import ActionException, PyWeMoException @@ -145,9 +145,10 @@ async def async_shutdown(self) -> None: if self._shutdown_requested: return await super().async_shutdown() - if TYPE_CHECKING: - # mypy doesn't known that the device_id is set in async_setup. - assert self.device_id is not None + if self.device_id is None: + # async_refresh failed in async_register_device before async_setup + # was called, so this coordinator was never fully registered. + return del _async_coordinators(self.hass)[self.device_id] assert self.options # Always set by async_register_device. if self.options.enable_subscription: diff --git a/homeassistant/components/wemo/entity.py b/homeassistant/components/wemo/entity.py index 16ab3ae11732a2..9ca690af6b4180 100644 --- a/homeassistant/components/wemo/entity.py +++ b/homeassistant/components/wemo/entity.py @@ -2,9 +2,9 @@ from __future__ import annotations -from collections.abc import Generator -import contextlib +from collections.abc import Callable import logging +from typing import Any from pywemo.exceptions import ActionException @@ -64,23 +64,20 @@ def device_info(self) -> DeviceInfo: """Return the device info.""" return self._device_info - @contextlib.contextmanager - def _wemo_call_wrapper(self, message: str) -> Generator[None]: - """Wrap calls to the device that change its state. + async def _async_wemo_call(self, message: str, action: Callable[[], Any]) -> None: + """Run a WeMo device action in the executor and update listeners. - 1. Takes care of making available=False when communications with the - device fails. - 2. Ensures all entities sharing the same coordinator are aware of - updates to the device state. + Handles errors from the device and ensures all entities sharing the + same coordinator are aware of updates to the device state. """ try: - yield + await self.hass.async_add_executor_job(action) except ActionException as err: _LOGGER.warning("Could not %s for %s (%s)", message, self.name, err) self.coordinator.last_exception = err - self.coordinator.last_update_success = False # Used for self.available. + self.coordinator.last_update_success = False finally: - self.hass.add_job(self.coordinator.async_update_listeners) + self.coordinator.async_update_listeners() class WemoBinaryStateEntity(WemoEntity): diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py index edfdfc1c78c8ab..491c2fcfe72540 100644 --- a/homeassistant/components/wemo/fan.py +++ b/homeassistant/components/wemo/fan.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import timedelta +import functools as ft import math from typing import Any @@ -60,14 +61,16 @@ async def _discovered_wemo(coordinator: DeviceCoordinator) -> None: platform = entity_platform.async_get_current_platform() - # This will call WemoHumidifier.set_humidity(target_humidity=VALUE) + # This will call WemoHumidifier.async_set_humidity(target_humidity=VALUE) platform.async_register_entity_service( - SERVICE_SET_HUMIDITY, SET_HUMIDITY_SCHEMA, WemoHumidifier.set_humidity.__name__ + SERVICE_SET_HUMIDITY, + SET_HUMIDITY_SCHEMA, + WemoHumidifier.async_set_humidity.__name__, ) - # This will call WemoHumidifier.reset_filter_life() + # This will call WemoHumidifier.async_reset_filter_life() platform.async_register_entity_service( - SERVICE_RESET_FILTER_LIFE, None, WemoHumidifier.reset_filter_life.__name__ + SERVICE_RESET_FILTER_LIFE, None, WemoHumidifier.async_reset_filter_life.__name__ ) @@ -124,25 +127,26 @@ def _handle_coordinator_update(self) -> None: self._last_fan_on_mode = self.wemo.fan_mode super()._handle_coordinator_update() - def turn_on( + async def async_turn_on( self, percentage: int | None = None, preset_mode: str | None = None, **kwargs: Any, ) -> None: """Turn the fan on.""" - self._set_percentage(percentage) + await self._async_set_percentage(percentage) - def turn_off(self, **kwargs: Any) -> None: - """Turn the switch off.""" - with self._wemo_call_wrapper("turn off"): - self.wemo.set_state(FanMode.Off) + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the fan off.""" + await self._async_wemo_call( + "turn off", ft.partial(self.wemo.set_state, FanMode.Off) + ) - def set_percentage(self, percentage: int) -> None: + async def async_set_percentage(self, percentage: int) -> None: """Set the fan_mode of the Humidifier.""" - self._set_percentage(percentage) + await self._async_set_percentage(percentage) - def _set_percentage(self, percentage: int | None) -> None: + async def _async_set_percentage(self, percentage: int | None) -> None: if percentage is None: named_speed = self._last_fan_on_mode elif percentage == 0: @@ -152,10 +156,11 @@ def _set_percentage(self, percentage: int | None) -> None: math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) ) - with self._wemo_call_wrapper("set speed"): - self.wemo.set_state(named_speed) + await self._async_wemo_call( + "set speed", ft.partial(self.wemo.set_state, named_speed) + ) - def set_humidity(self, target_humidity: float) -> None: + async def async_set_humidity(self, target_humidity: float) -> None: """Set the target humidity level for the Humidifier.""" if target_humidity < 50: pywemo_humidity = DesiredHumidity.FortyFivePercent @@ -168,10 +173,10 @@ def set_humidity(self, target_humidity: float) -> None: elif target_humidity >= 100: pywemo_humidity = DesiredHumidity.OneHundredPercent - with self._wemo_call_wrapper("set humidity"): - self.wemo.set_humidity(pywemo_humidity) + await self._async_wemo_call( + "set humidity", ft.partial(self.wemo.set_humidity, pywemo_humidity) + ) - def reset_filter_life(self) -> None: + async def async_reset_filter_life(self) -> None: """Reset the filter life to 100%.""" - with self._wemo_call_wrapper("reset filter life"): - self.wemo.reset_filter_life() + await self._async_wemo_call("reset filter life", self.wemo.reset_filter_life) diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py index 6d032a0a7b611f..1a349e8bacd5e2 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -2,6 +2,7 @@ from __future__ import annotations +import functools as ft from typing import Any, cast from pywemo import Bridge, BridgeLight, Dimmer @@ -166,7 +167,7 @@ def is_on(self) -> bool: """Return true if device is on.""" return self.light.state.get("onoff", WEMO_OFF) != WEMO_OFF - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" xy_color = None @@ -184,7 +185,7 @@ def turn_on(self, **kwargs: Any) -> None: "force_update": False, } - with self._wemo_call_wrapper("turn on"): + def _turn_on() -> None: if xy_color is not None: self.light.set_color(xy_color, transition=transition_time) @@ -195,12 +196,14 @@ def turn_on(self, **kwargs: Any) -> None: self.light.turn_on(**turn_on_kwargs) - def turn_off(self, **kwargs: Any) -> None: + await self._async_wemo_call("turn on", _turn_on) + + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" transition_time = int(kwargs.get(ATTR_TRANSITION, 0)) - - with self._wemo_call_wrapper("turn off"): - self.light.turn_off(transition=transition_time) + await self._async_wemo_call( + "turn off", ft.partial(self.light.turn_off, transition=transition_time) + ) class WemoDimmer(WemoBinaryStateEntity, LightEntity): @@ -216,20 +219,19 @@ def brightness(self) -> int: wemo_brightness: int = self.wemo.get_brightness() return int((wemo_brightness * 255) / 100) - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the dimmer on.""" # Wemo dimmer switches use a range of [0, 100] to control # brightness. Level 255 might mean to set it to previous value if ATTR_BRIGHTNESS in kwargs: brightness = kwargs[ATTR_BRIGHTNESS] brightness = int((brightness / 255) * 100) - with self._wemo_call_wrapper("set brightness"): - self.wemo.set_brightness(brightness) + await self._async_wemo_call( + "set brightness", ft.partial(self.wemo.set_brightness, brightness) + ) else: - with self._wemo_call_wrapper("turn on"): - self.wemo.on() + await self._async_wemo_call("turn on", self.wemo.on) - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the dimmer off.""" - with self._wemo_call_wrapper("turn off"): - self.wemo.off() + await self._async_wemo_call("turn off", self.wemo.off) diff --git a/homeassistant/components/wemo/switch.py b/homeassistant/components/wemo/switch.py index 7b87b3147d06f0..433736c64d7d68 100644 --- a/homeassistant/components/wemo/switch.py +++ b/homeassistant/components/wemo/switch.py @@ -119,12 +119,10 @@ def icon(self) -> str | None: return "mdi:coffee" return None - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - with self._wemo_call_wrapper("turn on"): - self.wemo.on() + await self._async_wemo_call("turn on", self.wemo.on) - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - with self._wemo_call_wrapper("turn off"): - self.wemo.off() + await self._async_wemo_call("turn off", self.wemo.off) diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py index 56cdf52c649d3b..d9b3eb3405643b 100644 --- a/homeassistant/components/whirlpool/__init__.py +++ b/homeassistant/components/whirlpool/__init__.py @@ -17,7 +17,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SELECT, Platform.SENSOR] type WhirlpoolConfigEntry = ConfigEntry[AppliancesManager] @@ -35,7 +35,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) -> try: await auth.do_auth(store=False) except (ClientError, TimeoutError) as ex: - raise ConfigEntryNotReady("Cannot connect") from ex + raise ConfigEntryNotReady( + translation_domain=DOMAIN, translation_key="cannot_connect" + ) from ex except WhirlpoolAccountLocked as ex: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="account_locked" @@ -43,7 +45,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) -> if not auth.is_access_token_valid(): _LOGGER.error("Authentication failed") - raise ConfigEntryAuthFailed("Incorrect Password") + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="invalid_auth" + ) appliances_manager = AppliancesManager(backend_selector, auth, session) if not await appliances_manager.fetch_appliances(): diff --git a/homeassistant/components/whirlpool/config_flow.py b/homeassistant/components/whirlpool/config_flow.py index d89e4d88d56261..cf5d437b0992ef 100644 --- a/homeassistant/components/whirlpool/config_flow.py +++ b/homeassistant/components/whirlpool/config_flow.py @@ -75,6 +75,7 @@ async def authenticate( and not appliances_manager.washers and not appliances_manager.dryers and not appliances_manager.ovens + and not appliances_manager.refrigerators ): return "no_appliances" diff --git a/homeassistant/components/whirlpool/const.py b/homeassistant/components/whirlpool/const.py index 163229e4a21bb6..eca61d1d852863 100644 --- a/homeassistant/components/whirlpool/const.py +++ b/homeassistant/components/whirlpool/const.py @@ -14,4 +14,5 @@ "Whirlpool": Brand.Whirlpool, "Maytag": Brand.Maytag, "KitchenAid": Brand.KitchenAid, + "Consul": Brand.Consul, } diff --git a/homeassistant/components/whirlpool/diagnostics.py b/homeassistant/components/whirlpool/diagnostics.py index fed999b881cb3f..6ff57ffdb6738a 100644 --- a/homeassistant/components/whirlpool/diagnostics.py +++ b/homeassistant/components/whirlpool/diagnostics.py @@ -52,6 +52,10 @@ def get_appliance_diagnostics(appliance: Appliance) -> dict[str, Any]: oven.name: get_appliance_diagnostics(oven) for oven in appliances_manager.ovens }, + "refrigerators": { + refrigerator.name: get_appliance_diagnostics(refrigerator) + for refrigerator in appliances_manager.refrigerators + }, } return { diff --git a/homeassistant/components/whirlpool/quality_scale.yaml b/homeassistant/components/whirlpool/quality_scale.yaml index 0348563fb6cec4..b7d8b490bdd2b1 100644 --- a/homeassistant/components/whirlpool/quality_scale.yaml +++ b/homeassistant/components/whirlpool/quality_scale.yaml @@ -62,7 +62,7 @@ rules: comment: The "unknown" state should not be part of the enum for the dispense level sensor. entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: status: todo comment: | diff --git a/homeassistant/components/whirlpool/select.py b/homeassistant/components/whirlpool/select.py new file mode 100644 index 00000000000000..3b65969b371831 --- /dev/null +++ b/homeassistant/components/whirlpool/select.py @@ -0,0 +1,88 @@ +"""The select platform for Whirlpool Appliances.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Final, override + +from whirlpool.appliance import Appliance + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import WhirlpoolConfigEntry +from .const import DOMAIN +from .entity import WhirlpoolEntity + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class WhirlpoolSelectDescription(SelectEntityDescription): + """Class describing Whirlpool select entities.""" + + value_fn: Callable[[Appliance], str | None] + set_fn: Callable[[Appliance, str], Awaitable[bool]] + + +REFRIGERATOR_DESCRIPTIONS: Final[tuple[WhirlpoolSelectDescription, ...]] = ( + WhirlpoolSelectDescription( + key="refrigerator_temperature_level", + translation_key="refrigerator_temperature_level", + options=["-4", "-2", "0", "3", "5"], + unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda fridge: ( + str(val) if (val := fridge.get_offset_temp()) is not None else None + ), + set_fn=lambda fridge, option: fridge.set_offset_temp(int(option)), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: WhirlpoolConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the select platform.""" + appliances_manager = config_entry.runtime_data + + async_add_entities( + WhirlpoolSelectEntity(refrigerator, description) + for refrigerator in appliances_manager.refrigerators + for description in REFRIGERATOR_DESCRIPTIONS + ) + + +class WhirlpoolSelectEntity(WhirlpoolEntity, SelectEntity): + """Whirlpool select entity.""" + + def __init__( + self, appliance: Appliance, description: WhirlpoolSelectDescription + ) -> None: + """Initialize the select entity.""" + super().__init__(appliance, unique_id_suffix=f"-{description.key}") + self.entity_description: WhirlpoolSelectDescription = description + + @override + @property + def current_option(self) -> str | None: + """Retrieve currently selected option.""" + return self.entity_description.value_fn(self._appliance) + + @override + async def async_select_option(self, option: str) -> None: + """Set the selected option.""" + try: + WhirlpoolSelectEntity._check_service_request( + await self.entity_description.set_fn(self._appliance, option) + ) + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_value_set", + ) from err diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index b1c0caf7c94277..f7c2d004b0e805 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -46,6 +46,11 @@ } }, "entity": { + "select": { + "refrigerator_temperature_level": { + "name": "Temperature level" + } + }, "sensor": { "dryer_state": { "name": "[%key:component::whirlpool::entity::sensor::washer_state::name%]", @@ -211,6 +216,15 @@ "appliances_fetch_failed": { "message": "Failed to fetch appliances" }, + "cannot_connect": { + "message": "[%key:common::config_flow::error::cannot_connect%]" + }, + "invalid_auth": { + "message": "[%key:common::config_flow::error::invalid_auth%]" + }, + "invalid_value_set": { + "message": "Invalid value provided" + }, "request_failed": { "message": "Request failed" } diff --git a/homeassistant/components/whois/__init__.py b/homeassistant/components/whois/__init__.py index 07116825f2946a..6f6462cd48b37e 100644 --- a/homeassistant/components/whois/__init__.py +++ b/homeassistant/components/whois/__init__.py @@ -2,44 +2,16 @@ from __future__ import annotations -from whois import Domain, query as whois_query -from whois.exceptions import ( - FailedParsingWhoisOutput, - UnknownDateFormat, - UnknownTld, - WhoisCommandFailed, -) - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, LOGGER, PLATFORMS, SCAN_INTERVAL +from .const import DOMAIN, PLATFORMS +from .coordinator import WhoisCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up from a config entry.""" - - async def _async_query_domain() -> Domain | None: - """Query WHOIS for domain information.""" - try: - return await hass.async_add_executor_job( - whois_query, entry.data[CONF_DOMAIN] - ) - except UnknownTld as ex: - raise UpdateFailed("Could not set up whois, TLD is unknown") from ex - except (FailedParsingWhoisOutput, WhoisCommandFailed, UnknownDateFormat) as ex: - raise UpdateFailed("An error occurred during WHOIS lookup") from ex - - coordinator: DataUpdateCoordinator[Domain | None] = DataUpdateCoordinator( - hass, - LOGGER, - config_entry=entry, - name=f"{DOMAIN}_APK", - update_interval=SCAN_INTERVAL, - update_method=_async_query_domain, - ) + coordinator = WhoisCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/whois/coordinator.py b/homeassistant/components/whois/coordinator.py new file mode 100644 index 00000000000000..6344e8a72e8eac --- /dev/null +++ b/homeassistant/components/whois/coordinator.py @@ -0,0 +1,45 @@ +"""DataUpdateCoordinator for the Whois integration.""" + +from __future__ import annotations + +from whois import Domain, query as whois_query +from whois.exceptions import ( + FailedParsingWhoisOutput, + UnknownDateFormat, + UnknownTld, + WhoisCommandFailed, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER, SCAN_INTERVAL + + +class WhoisCoordinator(DataUpdateCoordinator[Domain | None]): + """Class to manage fetching WHOIS data.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self) -> Domain | None: + """Query WHOIS for domain information.""" + try: + return await self.hass.async_add_executor_job( + whois_query, self.config_entry.data[CONF_DOMAIN] + ) + except UnknownTld as ex: + raise UpdateFailed("Could not set up whois, TLD is unknown") from ex + except (FailedParsingWhoisOutput, WhoisCommandFailed, UnknownDateFormat) as ex: + raise UpdateFailed("An error occurred during WHOIS lookup") from ex diff --git a/homeassistant/components/whois/diagnostics.py b/homeassistant/components/whois/diagnostics.py index 0f93461d8d8a36..ad7d8cd7164d1d 100644 --- a/homeassistant/components/whois/diagnostics.py +++ b/homeassistant/components/whois/diagnostics.py @@ -4,25 +4,25 @@ from typing import Any -from whois import Domain - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN +from .coordinator import WhoisCoordinator async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: DataUpdateCoordinator[Domain] = hass.data[DOMAIN][entry.entry_id] + coordinator: WhoisCoordinator = hass.data[DOMAIN][entry.entry_id] + if (data := coordinator.data) is None: + return {} return { - "creation_date": coordinator.data.creation_date, - "expiration_date": coordinator.data.expiration_date, - "last_updated": coordinator.data.last_updated, - "status": coordinator.data.status, - "statuses": coordinator.data.statuses, - "dnssec": coordinator.data.dnssec, + "creation_date": data.creation_date, + "expiration_date": data.expiration_date, + "last_updated": data.last_updated, + "status": data.status, + "statuses": data.statuses, + "dnssec": data.dnssec, } diff --git a/homeassistant/components/whois/sensor.py b/homeassistant/components/whois/sensor.py index fdc5e8384f9541..c30afbe3ac7753 100644 --- a/homeassistant/components/whois/sensor.py +++ b/homeassistant/components/whois/sensor.py @@ -19,10 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util from .const import ( @@ -33,6 +30,7 @@ DOMAIN, STATUS_TYPES, ) +from .coordinator import WhoisCoordinator @dataclass(frozen=True, kw_only=True) @@ -164,9 +162,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform from config_entry.""" - coordinator: DataUpdateCoordinator[Domain | None] = hass.data[DOMAIN][ - entry.entry_id - ] + coordinator: WhoisCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( [ WhoisSensorEntity( @@ -179,9 +175,7 @@ async def async_setup_entry( ) -class WhoisSensorEntity( - CoordinatorEntity[DataUpdateCoordinator[Domain | None]], SensorEntity -): +class WhoisSensorEntity(CoordinatorEntity[WhoisCoordinator], SensorEntity): """Implementation of a WHOIS sensor.""" entity_description: WhoisSensorEntityDescription @@ -189,7 +183,7 @@ class WhoisSensorEntity( def __init__( self, - coordinator: DataUpdateCoordinator[Domain | None], + coordinator: WhoisCoordinator, description: WhoisSensorEntityDescription, domain: str, ) -> None: diff --git a/homeassistant/components/wiffi/manifest.json b/homeassistant/components/wiffi/manifest.json index 07dd237007c430..bd5949cc0443d3 100644 --- a/homeassistant/components/wiffi/manifest.json +++ b/homeassistant/components/wiffi/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@mampfes"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wiffi", + "integration_type": "hub", "iot_class": "local_push", "loggers": ["wiffi"], "requirements": ["wiffi==1.1.2"] diff --git a/homeassistant/components/wiim/__init__.py b/homeassistant/components/wiim/__init__.py new file mode 100644 index 00000000000000..e6407467db253e --- /dev/null +++ b/homeassistant/components/wiim/__init__.py @@ -0,0 +1,128 @@ +"""The WiiM integration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from urllib.parse import urlparse + +from wiim.controller import WiimController +from wiim.discovery import async_create_wiim_device +from wiim.exceptions import WiimDeviceException, WiimRequestException + +from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.network import NoURLAvailableError, get_url + +from .const import DATA_WIIM, DOMAIN, LOGGER, PLATFORMS, UPNP_PORT, WiimConfigEntry +from .models import WiimData + +DEFAULT_AVAILABILITY_POLLING_INTERVAL = 60 + + +async def async_setup_entry(hass: HomeAssistant, entry: WiimConfigEntry) -> bool: + """Set up WiiM from a config entry. + + This method owns the device connect/disconnect lifecycle. + """ + LOGGER.debug( + "Setting up WiiM entry: %s (UDN: %s, Source: %s)", + entry.title, + entry.unique_id, + entry.source, + ) + + # This integration maintains shared domain-level state because: + # - Multiple config entries can be loaded simultaneously. + # - All WiiM devices share a single WiimController instance + # to coordinate network communication and event handling. + # - We also maintain a global entity_id -> UDN mapping + # used for cross-entity event routing. + # + # The domain data must therefore be initialized once and reused + # across all config entries. + session = async_get_clientsession(hass) + + if DATA_WIIM not in hass.data: + hass.data[DATA_WIIM] = WiimData(controller=WiimController(session)) + + wiim_domain_data = hass.data[DATA_WIIM] + controller = wiim_domain_data.controller + + host = entry.data[CONF_HOST] + upnp_location = f"http://{host}:{UPNP_PORT}/description.xml" + + try: + base_url = get_url(hass, prefer_external=False) + except NoURLAvailableError as err: + raise ConfigEntryNotReady("Failed to determine Home Assistant URL") from err + + local_host = urlparse(base_url).hostname + if TYPE_CHECKING: + assert local_host is not None + + try: + wiim_device = await async_create_wiim_device( + upnp_location, + session, + host=host, + local_host=local_host, + polling_interval=DEFAULT_AVAILABILITY_POLLING_INTERVAL, + ) + except WiimRequestException as err: + raise ConfigEntryNotReady(f"HTTP API request failed for {host}: {err}") from err + except WiimDeviceException as err: + raise ConfigEntryNotReady(f"Device setup failed for {host}: {err}") from err + + await controller.add_device(wiim_device) + + entry.runtime_data = wiim_device + LOGGER.info( + "WiiM device %s (UDN: %s) linked to HASS. Name: '%s', HTTP: %s, UPnP Location: %s", + entry.entry_id, + wiim_device.udn, + wiim_device.name, + host, + upnp_location or "N/A", + ) + + async def _async_shutdown_event_handler(event: Event) -> None: + LOGGER.info( + "Home Assistant stopping, disconnecting WiiM device: %s", + wiim_device.name, + ) + await wiim_device.disconnect() + + entry.async_on_unload( + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, _async_shutdown_event_handler + ) + ) + + async def _unload_entry_cleanup(): + """Cleanup when unloading the config entry. + + Removes the device from the controller and disconnects it. + """ + LOGGER.debug("Running unload cleanup for %s", wiim_device.name) + await controller.remove_device(wiim_device.udn) + await wiim_device.disconnect() + + entry.async_on_unload(_unload_entry_cleanup) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: WiimConfigEntry) -> bool: + """Unload a config entry.""" + LOGGER.info("Unloading WiiM entry: %s (UDN: %s)", entry.title, entry.unique_id) + + if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + return False + + if not hass.config_entries.async_loaded_entries(DOMAIN): + hass.data.pop(DATA_WIIM) + LOGGER.info("Last WiiM entry unloaded, cleaning up domain data") + return True diff --git a/homeassistant/components/wiim/config_flow.py b/homeassistant/components/wiim/config_flow.py new file mode 100644 index 00000000000000..7283657462479b --- /dev/null +++ b/homeassistant/components/wiim/config_flow.py @@ -0,0 +1,132 @@ +"""Config flow for WiiM integration.""" + +from __future__ import annotations + +from typing import Any + +import voluptuous as vol +from wiim.discovery import async_probe_wiim_device +from wiim.models import WiimProbeResult + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from .const import DOMAIN, LOGGER, UPNP_PORT + +STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) + + +async def _async_probe_wiim_host(hass: HomeAssistant, host: str) -> WiimProbeResult: + """Probe the given host and return WiiM device information.""" + session = async_get_clientsession(hass) + location = f"http://{host}:{UPNP_PORT}/description.xml" + LOGGER.debug("Validating UPnP device at location: %s", location) + try: + probe_result = await async_probe_wiim_device( + location, + session, + host=host, + ) + except TimeoutError as err: + raise CannotConnect from err + + if probe_result is None: + raise CannotConnect + return probe_result + + +class WiimConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for WiiM.""" + + _discovered_info: WiimProbeResult | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step when user adds integration manually.""" + errors: dict[str, str] = {} + if user_input is not None: + host = user_input[CONF_HOST] + try: + device_info = await _async_probe_wiim_host(self.hass, host) + except CannotConnect: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(device_info.udn) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=device_info.name, + data={ + CONF_HOST: device_info.host, + }, + ) + + 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_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle Zeroconf discovery.""" + LOGGER.debug( + "Zeroconf discovery received: Name: %s, Host: %s, Port: %s, Properties: %s", + discovery_info.name, + discovery_info.host, + discovery_info.port, + discovery_info.properties, + ) + + host = discovery_info.host + udn_from_txt = discovery_info.properties.get("uuid") + if udn_from_txt: + await self.async_set_unique_id(udn_from_txt) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + + try: + device_info = await _async_probe_wiim_host(self.hass, host) + except CannotConnect: + return self.async_abort(reason="cannot_connect") + + await self.async_set_unique_id(device_info.udn) + self._abort_if_unique_id_configured(updates={CONF_HOST: device_info.host}) + + self._discovered_info = device_info + self.context["title_placeholders"] = {"name": device_info.name} + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle user confirmation of discovered device.""" + discovered_info = self._discovered_info + if user_input is not None and discovered_info is not None: + return self.async_create_entry( + title=discovered_info.name, + data={ + CONF_HOST: discovered_info.host, + }, + ) + + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={ + "name": ( + discovered_info.name + if discovered_info is not None + else "Discovered WiiM Device" + ) + }, + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/wiim/const.py b/homeassistant/components/wiim/const.py new file mode 100644 index 00000000000000..a1504968865448 --- /dev/null +++ b/homeassistant/components/wiim/const.py @@ -0,0 +1,27 @@ +"""Constants for the WiiM integration.""" + +import logging +from typing import TYPE_CHECKING, Final + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from wiim import WiimDevice + + from .models import WiimData + +type WiimConfigEntry = ConfigEntry[WiimDevice] + +DOMAIN: Final = "wiim" +LOGGER = logging.getLogger(__package__) +DATA_WIIM: HassKey[WiimData] = HassKey(DOMAIN) + +PLATFORMS: Final[list[Platform]] = [ + Platform.MEDIA_PLAYER, +] + +UPNP_PORT = 49152 + +ZEROCONF_TYPE_LINKPLAY: Final = "_linkplay._tcp.local." diff --git a/homeassistant/components/wiim/entity.py b/homeassistant/components/wiim/entity.py new file mode 100644 index 00000000000000..3c1dbcbafa97bd --- /dev/null +++ b/homeassistant/components/wiim/entity.py @@ -0,0 +1,36 @@ +"""Base entity for the WiiM integration.""" + +from __future__ import annotations + +from wiim.wiim_device import WiimDevice + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class WiimBaseEntity(Entity): + """Base representation of a WiiM entity.""" + + _attr_has_entity_name = True + + def __init__(self, wiim_device: WiimDevice) -> None: + """Initialize the WiiM base entity.""" + self._device = wiim_device + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, self._device.udn)}, + name=self._device.name, + manufacturer=self._device.manufacturer, + model=self._device.model_name, + sw_version=self._device.firmware_version, + ) + if self._device.presentation_url: + self._attr_device_info["configuration_url"] = self._device.presentation_url + elif self._device.http_api_url: + self._attr_device_info["configuration_url"] = self._device.http_api_url + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._device.available diff --git a/homeassistant/components/wiim/manifest.json b/homeassistant/components/wiim/manifest.json new file mode 100644 index 00000000000000..f9080754a74b58 --- /dev/null +++ b/homeassistant/components/wiim/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "wiim", + "name": "WiiM", + "codeowners": ["@Linkplay2020"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/wiim", + "integration_type": "hub", + "iot_class": "local_push", + "loggers": ["wiim.sdk", "async_upnp_client"], + "quality_scale": "bronze", + "requirements": ["wiim==0.1.0"], + "zeroconf": ["_linkplay._tcp.local."] +} diff --git a/homeassistant/components/wiim/media_player.py b/homeassistant/components/wiim/media_player.py new file mode 100644 index 00000000000000..dbb8d8edb8bee0 --- /dev/null +++ b/homeassistant/components/wiim/media_player.py @@ -0,0 +1,794 @@ +"""Support for WiiM Media Players.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Coroutine +from functools import wraps +from typing import Any, Concatenate + +from async_upnp_client.client import UpnpService, UpnpStateVariable +from wiim.consts import PlayingStatus as SDKPlayingStatus +from wiim.exceptions import WiimDeviceException, WiimException, WiimRequestException +from wiim.models import ( + WiimGroupRole, + WiimGroupSnapshot, + WiimRepeatMode, + WiimTransportCapabilities, +) +from wiim.wiim_device import WiimDevice + +from homeassistant.components import media_source +from homeassistant.components.media_player import ( + BrowseError, + BrowseMedia, + MediaClass, + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, + MediaType, + RepeatMode, + async_process_play_media_url, +) +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.dt import utcnow + +from .const import DATA_WIIM, LOGGER, WiimConfigEntry +from .entity import WiimBaseEntity +from .models import WiimData + +MEDIA_TYPE_WIIM_LIBRARY = "wiim_library" +MEDIA_CONTENT_ID_ROOT = "library_root" +MEDIA_CONTENT_ID_FAVORITES = ( + f"{MEDIA_TYPE_WIIM_LIBRARY}/{MEDIA_CONTENT_ID_ROOT}/favorites" +) +MEDIA_CONTENT_ID_PLAYLISTS = ( + f"{MEDIA_TYPE_WIIM_LIBRARY}/{MEDIA_CONTENT_ID_ROOT}/playlists" +) + +SDK_TO_HA_STATE: dict[SDKPlayingStatus, MediaPlayerState] = { + SDKPlayingStatus.PLAYING: MediaPlayerState.PLAYING, + SDKPlayingStatus.PAUSED: MediaPlayerState.PAUSED, + SDKPlayingStatus.STOPPED: MediaPlayerState.IDLE, + SDKPlayingStatus.LOADING: MediaPlayerState.BUFFERING, +} + +# Define supported features +SUPPORT_WIIM_BASE = ( + MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.STOP + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.SELECT_SOURCE + | MediaPlayerEntityFeature.GROUPING + | MediaPlayerEntityFeature.SEEK +) + + +def media_player_exception_wrap[ + _WiimMediaPlayerEntityT: "WiimMediaPlayerEntity", + **_P, + _R, +]( + func: Callable[Concatenate[_WiimMediaPlayerEntityT, _P], Awaitable[_R]], +) -> Callable[Concatenate[_WiimMediaPlayerEntityT, _P], Coroutine[Any, Any, _R]]: + """Wrap media player commands to handle SDK exceptions consistently.""" + + @wraps(func) + async def _wrap( + self: _WiimMediaPlayerEntityT, *args: _P.args, **kwargs: _P.kwargs + ) -> _R: + try: + result = await func(self, *args, **kwargs) + except (WiimDeviceException, WiimRequestException, WiimException) as err: + await self._async_handle_critical_error(err) + raise HomeAssistantError( + f"{func.__name__} failed for {self.entity_id}" + ) from err + except RuntimeError as err: + raise HomeAssistantError( + f"{func.__name__} failed for {self.entity_id}" + ) from err + + self._update_ha_state_from_sdk_cache() + + return result + + return _wrap + + +async def async_setup_entry( + hass: HomeAssistant, + entry: WiimConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up WiiM media player from a config entry.""" + async_add_entities([WiimMediaPlayerEntity(entry.runtime_data, entry)]) + + +class WiimMediaPlayerEntity(WiimBaseEntity, MediaPlayerEntity): + """Representation of a WiiM media player.""" + + _attr_device_class = MediaPlayerDeviceClass.SPEAKER + _attr_media_image_remotely_accessible = True + _attr_name = None + _attr_should_poll = False + + def __init__(self, device: WiimDevice, entry: WiimConfigEntry) -> None: + """Initialize the WiiM entity.""" + super().__init__(device) + self._entry = entry + + self._attr_unique_id = device.udn + self._attr_source_list = list(device.supported_input_modes) or None + self._attr_shuffle: bool = False + self._attr_repeat = RepeatMode.OFF + self._transport_capabilities: WiimTransportCapabilities | None = None + self._supported_features_update_in_flight = False + + @property + def _wiim_data(self) -> WiimData: + """Return shared WiiM domain data.""" + return self.hass.data[DATA_WIIM] + + @property + def supported_features(self) -> MediaPlayerEntityFeature: + """Return the features supported by the current device state.""" + features = SUPPORT_WIIM_BASE + if self._transport_capabilities is None: + return features + + if self._transport_capabilities.can_next: + features |= MediaPlayerEntityFeature.NEXT_TRACK + if self._transport_capabilities.can_previous: + features |= MediaPlayerEntityFeature.PREVIOUS_TRACK + if self._transport_capabilities.can_repeat: + features |= MediaPlayerEntityFeature.REPEAT_SET + if self._transport_capabilities.can_shuffle: + features |= MediaPlayerEntityFeature.SHUFFLE_SET + + return features + + @callback + def _get_entity_id_for_udn(self, udn: str) -> str | None: + """Helper to get a WiimMediaPlayerEntity ID by UDN from shared data.""" + for entity_id, stored_udn in self._wiim_data.entity_id_to_udn_map.items(): + if stored_udn == udn: + return entity_id + + LOGGER.debug("No entity ID found for UDN: %s", udn) + return None + + def _get_group_snapshot(self) -> WiimGroupSnapshot: + """Return the typed group snapshot for the current device.""" + return self._wiim_data.controller.get_group_snapshot(self._device.udn) + + @property + def _metadata_device(self) -> WiimDevice: + """Return the device whose metadata should back this entity.""" + group_snapshot = self._get_group_snapshot() + if group_snapshot.role != WiimGroupRole.FOLLOWER: + return self._device + + return self._wiim_data.controller.get_device(group_snapshot.leader_udn) + + @callback + def _clear_media_metadata(self) -> None: + """Clear media metadata attributes.""" + self._attr_media_title = None + self._attr_media_artist = None + self._attr_media_album_name = None + self._attr_media_image_url = None + self._attr_media_content_id = None + self._attr_media_content_type = None + self._attr_media_duration = None + self._attr_media_position = None + self._attr_media_position_updated_at = None + + @callback + def _get_command_target_device(self, action_name: str) -> WiimDevice: + """Return the device that should receive a grouped playback command.""" + group_snapshot = self._get_group_snapshot() + if group_snapshot.role != WiimGroupRole.FOLLOWER: + return self._device + + target_device = self._wiim_data.controller.get_device( + group_snapshot.command_target_udn + ) + + LOGGER.info( + "Routing %s command from follower %s to leader %s", + action_name, + self.entity_id, + target_device.udn, + ) + return target_device + + @callback + def _update_ha_state_from_sdk_cache( + self, + *, + write_state: bool = True, + update_supported_features: bool = True, + ) -> None: + """Update HA state from SDK's cache/HTTP poll attributes. + + This is the main method for updating this entity's HA attributes. + Crucially, it also handles propagating metadata to followers if this is a leader. + """ + LOGGER.debug( + "Device %s: Updating HA state from SDK cache/HTTP poll", + self.name or self.unique_id, + ) + self._attr_available = self._device.available + + if not self._attr_available: + self._attr_state = None + self._clear_media_metadata() + self._attr_source = None + self._transport_capabilities = None + if write_state: + self.async_write_ha_state() + return + + # Update common attributes first + self._attr_volume_level = self._device.volume / 100 + self._attr_is_volume_muted = self._device.is_muted + self._attr_source_list = list(self._device.supported_input_modes) or None + + # Determine current group role (leader/follower/standalone) + group_snapshot = self._get_group_snapshot() + + metadata_device = self._metadata_device + if group_snapshot.role == WiimGroupRole.FOLLOWER: + LOGGER.debug( + "Follower %s: Actively pulling metadata from leader %s", + self.entity_id, + metadata_device.udn, + ) + + if metadata_device.playing_status is not None: + self._attr_state = SDK_TO_HA_STATE.get( + metadata_device.playing_status, MediaPlayerState.IDLE + ) + + if metadata_device.play_mode is not None: + self._attr_source = metadata_device.play_mode + + loop_state = metadata_device.loop_state + self._attr_repeat = RepeatMode(loop_state.repeat) + self._attr_shuffle = loop_state.shuffle + + if media := metadata_device.current_media: + self._attr_media_title = media.title + self._attr_media_artist = media.artist + self._attr_media_album_name = media.album + self._attr_media_image_url = media.image_url + self._attr_media_content_id = media.uri + self._attr_media_content_type = MediaType.MUSIC + self._attr_media_duration = media.duration + if self._attr_media_position != media.position: + self._attr_media_position = media.position + self._attr_media_position_updated_at = utcnow() + else: + self._clear_media_metadata() + + group_members = [ + entity_id + for udn in group_snapshot.member_udns + if (entity_id := self._get_entity_id_for_udn(udn)) is not None + ] + self._attr_group_members = group_members or ([self.entity_id]) + + if update_supported_features: + self._async_schedule_update_supported_features() + + if write_state: + self.async_write_ha_state() + + @callback + def _handle_sdk_general_device_update(self, device: WiimDevice) -> None: + """Handle general updates from the SDK (e.g., availability, polled data).""" + LOGGER.debug( + "Device %s: Received general SDK update from %s", + self.entity_id, + device.name, + ) + if not self._device.available: + self._update_ha_state_from_sdk_cache() + self._entry.async_create_background_task( + self.hass, + self._async_handle_critical_error(WiimException("Device offline.")), + name=f"wiim_{self.entity_id}_critical_error", + ) + return + + async def _wrapped() -> None: + await self._device.ensure_subscriptions() + self._update_ha_state_from_sdk_cache() + + if self._device.supports_http_api: + self._entry.async_create_background_task( + self.hass, + _wrapped(), + name=f"wiim_{self.entity_id}_general_update", + ) + else: + self._update_ha_state_from_sdk_cache() + + @callback + def _handle_sdk_av_transport_event( + self, service: UpnpService, state_variables: list[UpnpStateVariable] + ) -> None: + """Handle AVTransport events from the SDK. + + This method updates the internal SDK device state based on events, + then triggers a full HA state refresh from the device's cache. + """ + + LOGGER.debug( + "Device %s: Received AVTransport event: %s", + self.entity_id, + self._device.event_data, + ) + + event_data = self._device.event_data + + if "TransportState" in event_data: + sdk_status_str = event_data["TransportState"] + try: + sdk_status = SDKPlayingStatus(sdk_status_str) + except ValueError: + LOGGER.warning( + "Device %s: Unknown TransportState from event: %s", + self.entity_id, + sdk_status_str, + ) + else: + self._device.playing_status = sdk_status + if sdk_status == SDKPlayingStatus.STOPPED: + LOGGER.debug( + "Device %s: TransportState is STOPPED. Resetting media position and metadata", + self.entity_id, + ) + self._device.current_position = 0 + self._device.current_track_duration = 0 + self._attr_media_position_updated_at = None + self._attr_media_duration = None + self._attr_media_position = None + elif sdk_status in {SDKPlayingStatus.PAUSED, SDKPlayingStatus.PLAYING}: + self._entry.async_create_background_task( + self.hass, + self._device.sync_device_duration_and_position(), + name=f"wiim_{self.entity_id}_sync_position", + ) + + self._update_ha_state_from_sdk_cache() + + @callback + def _handle_sdk_refresh_event( + self, _service: UpnpService, state_variables: list[UpnpStateVariable] + ) -> None: + """Handle SDK events that only require a state refresh.""" + LOGGER.debug( + "Device %s: Received SDK refresh event: %s", self.entity_id, state_variables + ) + self._update_ha_state_from_sdk_cache() + + async def _async_get_transport_capabilities_for_device( + self, device: WiimDevice + ) -> WiimTransportCapabilities | None: + """Return transport capabilities for a device.""" + try: + return await device.async_get_transport_capabilities() + except WiimRequestException as err: + LOGGER.warning( + "Device %s: Failed to fetch transport capabilities: %s", + device.udn, + err, + ) + return None + except RuntimeError as err: + LOGGER.error( + "Device %s: Unexpected error in transport capability detection: %s", + device.udn, + err, + ) + return None + + async def _from_device_update_supported_features( + self, *, write_state: bool = True + ) -> None: + """Fetches media info from the device to dynamically update supported features. + + This method is asynchronous and makes a network call. + """ + metadata_device = self._metadata_device + previous_capabilities = self._transport_capabilities + if ( + transport_capabilities + := await self._async_get_transport_capabilities_for_device(metadata_device) + ) is not None: + if self._transport_capabilities != transport_capabilities: + self._transport_capabilities = transport_capabilities + LOGGER.debug( + "Device %s: Updated transport capabilities to %s", + self.entity_id, + transport_capabilities, + ) + elif ( + metadata_device is not self._device + and self._transport_capabilities is not None + ): + self._transport_capabilities = None + LOGGER.debug( + "Device %s: Follower transport capabilities unavailable, using base features", + self.entity_id, + ) + + if write_state and self._transport_capabilities != previous_capabilities: + self.async_write_ha_state() + + @callback + def _async_schedule_update_supported_features(self) -> None: + """Update supported features based on current state.""" + # Avoid parallel MEDIA_INFO request. + if self._supported_features_update_in_flight: + return + + self._supported_features_update_in_flight = True + + async def _refresh_supported_features() -> None: + try: + await self._from_device_update_supported_features() + finally: + self._supported_features_update_in_flight = False + + self._entry.async_create_background_task( + self.hass, + _refresh_supported_features(), + name=f"wiim_{self.entity_id}_refresh_supported_features", + ) + + @callback + def _async_registry_updated( + self, event: Event[er.EventEntityRegistryUpdatedData] + ) -> None: + """Keep the entity-to-UDN map in sync with entity registry updates.""" + if ( + event.data["action"] == "update" + and (old_entity_id := event.data.get("old_entity_id")) + and old_entity_id != (entity_id := event.data["entity_id"]) + ): + self._wiim_data.entity_id_to_udn_map.pop(old_entity_id, None) + self._wiim_data.entity_id_to_udn_map[entity_id] = self._device.udn + + super()._async_registry_updated(event) + + async def async_added_to_hass(self) -> None: + """Run when entity is added to Home Assistant.""" + await super().async_added_to_hass() + self._wiim_data.entity_id_to_udn_map[self.entity_id] = self._device.udn + LOGGER.debug( + "Added %s (UDN: %s) to entity maps in hass.data", + self.entity_id, + self._device.udn, + ) + + await self._from_device_update_supported_features(write_state=False) + self._update_ha_state_from_sdk_cache( + write_state=False, update_supported_features=False + ) + self._device.general_event_callback = self._handle_sdk_general_device_update + self._device.av_transport_event_callback = self._handle_sdk_av_transport_event + self._device.rendering_control_event_callback = self._handle_sdk_refresh_event + self._device.play_queue_event_callback = self._handle_sdk_refresh_event + LOGGER.debug( + "Entity %s registered callbacks with WiimDevice %s", + self.entity_id, + self._device.name, + ) + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from Home Assistant.""" + # Unregister SDK callbacks + self._device.general_event_callback = None + self._device.av_transport_event_callback = None + self._device.rendering_control_event_callback = None + self._device.play_queue_event_callback = None + LOGGER.debug( + "Entity %s unregistered callbacks from WiimDevice %s", + self.entity_id, + self._device.name, + ) + self._wiim_data.entity_id_to_udn_map.pop(self.entity_id, None) + LOGGER.debug("Removed %s from entity_id_to_udn_map", self.entity_id) + + await super().async_will_remove_from_hass() + + async def _async_handle_critical_error(self, error: WiimException) -> None: + """Handle communication failures by marking the device unavailable.""" + if self._device.available: + LOGGER.info( + "Lost connection to WiiM device %s: %s", + self.entity_id, + error, + ) + self._device.set_available(False) + self._update_ha_state_from_sdk_cache() + + await self._wiim_data.controller.async_update_all_multiroom_status() + + @media_player_exception_wrap + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level, range 0-1.""" + await self._device.async_set_volume(round(volume * 100)) + + @media_player_exception_wrap + async def async_mute_volume(self, mute: bool) -> None: + """Mute (true) or unmute (false) media player.""" + await self._device.async_set_mute(mute) + + @media_player_exception_wrap + async def async_media_play(self) -> None: + """Send play command.""" + await self._get_command_target_device("media_play").async_play() + + @media_player_exception_wrap + async def async_media_pause(self) -> None: + """Send pause command.""" + target_device = self._get_command_target_device("media_pause") + await target_device.async_pause() + await target_device.sync_device_duration_and_position() + + @media_player_exception_wrap + async def async_media_stop(self) -> None: + """Send stop command.""" + await self._get_command_target_device("media_stop").async_stop() + + @media_player_exception_wrap + async def async_media_next_track(self) -> None: + """Send next track command.""" + await self._get_command_target_device("media_next_track").async_next() + + @media_player_exception_wrap + async def async_media_previous_track(self) -> None: + """Send previous track command.""" + await self._get_command_target_device("media_previous_track").async_previous() + + @media_player_exception_wrap + async def async_media_seek(self, position: float) -> None: + """Seek to a specific position in the track.""" + await self._get_command_target_device("media_seek").async_seek(int(position)) + + @media_player_exception_wrap + async def async_play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: + """Play a piece of media.""" + LOGGER.debug( + "async_play_media: type=%s, id=%s, kwargs=%s", media_type, media_id, kwargs + ) + target_device = self._get_command_target_device("play_media") + + if media_source.is_media_source_id(media_id): + play_item = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) + await self._async_play_url(target_device, play_item.url) + elif media_type == MEDIA_TYPE_WIIM_LIBRARY: + if not media_id.isdigit(): + raise ServiceValidationError(f"Invalid preset ID: {media_id}") + + preset_number = int(media_id) + await target_device.play_preset(preset_number) + self._attr_media_content_id = f"wiim_preset_{preset_number}" + self._attr_media_content_type = MediaType.PLAYLIST + self._attr_state = MediaPlayerState.PLAYING + elif media_type == MediaType.MUSIC: + if media_id.isdigit(): + preset_number = int(media_id) + await target_device.play_preset(preset_number) + self._attr_media_content_id = f"wiim_preset_{preset_number}" + self._attr_media_content_type = MediaType.PLAYLIST + self._attr_state = MediaPlayerState.PLAYING + else: + await self._async_play_url(target_device, media_id) + elif media_type == MediaType.URL: + await self._async_play_url(target_device, media_id) + elif media_type == MediaType.TRACK: + if not media_id.isdigit(): + raise ServiceValidationError( + f"Invalid media_id: {media_id}. Expected a valid track index." + ) + + track_index = int(media_id) + await target_device.async_play_queue_with_index(track_index) + self._attr_media_content_id = f"wiim_track_{track_index}" + self._attr_media_content_type = MediaType.TRACK + self._attr_state = MediaPlayerState.PLAYING + else: + raise ServiceValidationError(f"Unsupported media type: {media_type}") + + async def _async_play_url(self, target_device: WiimDevice, media_id: str) -> None: + """Play a direct media URL on the target device.""" + if not target_device.supports_http_api: + raise ServiceValidationError( + "Direct URL playback is not supported on this device" + ) + + url = async_process_play_media_url(self.hass, media_id) + LOGGER.debug("HTTP media_type for play_media: %s", url) + await target_device.play_url(url) + self._attr_state = MediaPlayerState.PLAYING + + @media_player_exception_wrap + async def async_set_repeat(self, repeat: RepeatMode) -> None: + """Set repeat mode.""" + target_device = self._get_command_target_device("repeat_set") + await target_device.async_set_loop_mode( + target_device.build_loop_mode(WiimRepeatMode(repeat), self._attr_shuffle) + ) + + @media_player_exception_wrap + async def async_set_shuffle(self, shuffle: bool) -> None: + """Enable/disable shuffle mode.""" + repeat = self._attr_repeat or WiimRepeatMode.OFF + target_device = self._get_command_target_device("shuffle_set") + await target_device.async_set_loop_mode( + target_device.build_loop_mode(WiimRepeatMode(repeat), shuffle) + ) + + @media_player_exception_wrap + async def async_select_source(self, source: str) -> None: + """Select input mode.""" + await self._get_command_target_device("select_source").async_set_play_mode( + source + ) + + async def async_browse_media( + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, + ) -> BrowseMedia: + """Implement media Browse helper.""" + LOGGER.debug( + "Browsing media: content_type=%s, content_id=%s", + media_content_type, + media_content_id, + ) + + if media_content_id is not None and media_source.is_media_source_id( + media_content_id + ): + if not self._device.supports_http_api: + raise BrowseError("Media sources are not supported on this device") + + return await media_source.async_browse_media( + self.hass, + media_content_id, + content_filter=lambda item: item.media_content_type.startswith( + "audio/" + ), + ) + + # Root browse + if media_content_id is None or media_content_id == MEDIA_CONTENT_ID_ROOT: + children: list[BrowseMedia] = [] + children.append( + BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id=MEDIA_CONTENT_ID_FAVORITES, + media_content_type=MediaType.PLAYLIST, + title="Presets", + can_play=False, + can_expand=True, + thumbnail=None, + ), + ) + children.append( + BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id=MEDIA_CONTENT_ID_PLAYLISTS, + media_content_type=MediaType.PLAYLIST, + title="Queue", + can_play=False, + can_expand=True, + thumbnail=None, + ), + ) + if self._device.supports_http_api: + media_sources_item = await media_source.async_browse_media( + self.hass, + None, + content_filter=lambda item: item.media_content_type.startswith( + "audio/" + ), + ) + + if media_sources_item.children: + children.extend(media_sources_item.children) + + return BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id=MEDIA_CONTENT_ID_ROOT, + media_content_type=MEDIA_TYPE_WIIM_LIBRARY, + title=self._device.name, + can_play=False, + can_expand=True, + children=children, + ) + + if media_content_id == MEDIA_CONTENT_ID_FAVORITES: + sdk_favorites = await self._device.async_get_presets() + favorites_items = [ + BrowseMedia( + media_class=MediaClass.PLAYLIST, + media_content_id=str(item.preset_id), + media_content_type=MediaType.MUSIC, + title=item.title, + can_play=True, + can_expand=False, + thumbnail=item.image_url, + ) + for item in sdk_favorites + ] + + return BrowseMedia( + media_class=MediaClass.PLAYLIST, + media_content_id=MEDIA_CONTENT_ID_FAVORITES, + media_content_type=MediaType.PLAYLIST, + title="Presets", + can_play=False, + can_expand=True, + children=favorites_items, + ) + + if media_content_id == MEDIA_CONTENT_ID_PLAYLISTS: + queue_snapshot = await self._device.async_get_queue_snapshot() + if not queue_snapshot.is_active: + return BrowseMedia( + media_class=MediaClass.PLAYLIST, + media_content_id=MEDIA_CONTENT_ID_PLAYLISTS, + media_content_type=MediaType.PLAYLIST, + title="Queue", + can_play=False, + can_expand=True, + children=[], + ) + + playlist_track_items = [ + BrowseMedia( + media_class=MediaClass.TRACK, + media_content_id=str(item.queue_index), + media_content_type=MediaType.TRACK, + title=item.title, + can_play=True, + can_expand=False, + thumbnail=item.image_url, + ) + for item in queue_snapshot.items + ] + + return BrowseMedia( + media_class=MediaClass.PLAYLIST, + media_content_id=MEDIA_CONTENT_ID_PLAYLISTS, + media_content_type=MediaType.PLAYLIST, + title="Queue", + can_play=False, + can_expand=True, + children=playlist_track_items, + ) + + LOGGER.warning( + "Unhandled browse_media request: content_type=%s, content_id=%s", + media_content_type, + media_content_id, + ) + raise BrowseError(f"Invalid browse path: {media_content_id}") diff --git a/homeassistant/components/wiim/models.py b/homeassistant/components/wiim/models.py new file mode 100644 index 00000000000000..43d347b7636b1f --- /dev/null +++ b/homeassistant/components/wiim/models.py @@ -0,0 +1,13 @@ +"""Runtime models for the WiiM integration.""" + +from dataclasses import dataclass, field + +from wiim.controller import WiimController + + +@dataclass +class WiimData: + """Runtime data for the WiiM integration shared across platforms.""" + + controller: WiimController + entity_id_to_udn_map: dict[str, str] = field(default_factory=dict) diff --git a/homeassistant/components/wiim/quality_scale.yaml b/homeassistant/components/wiim/quality_scale.yaml new file mode 100644 index 00000000000000..17d92d5fb24500 --- /dev/null +++ b/homeassistant/components/wiim/quality_scale.yaml @@ -0,0 +1,78 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + The integration does not provide any additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + # Silver + action-exceptions: + status: todo + comment: | + - The calls to the api can be changed to return bool, and services can then raise HomeAssistantError + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration has no configuration parameters + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: todo + test-coverage: + status: todo + comment: | + - Increase test coverage for the media_player platform + + # Gold + devices: done + diagnostics: todo + discovery-update-info: done + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: + status: todo + comment: | + Set appropriate device classes for all entities where applicable. + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No known use cases for repair issues or flows, yet + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/wiim/strings.json b/homeassistant/components/wiim/strings.json new file mode 100644 index 00000000000000..cb8bff55f0ee25 --- /dev/null +++ b/homeassistant/components/wiim/strings.json @@ -0,0 +1,35 @@ +{ + "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%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "flow_title": "{name}", + "step": { + "discovery_confirm": { + "description": "Do you want to set up {name}?" + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the WiiM device." + } + } + } + }, + "exceptions": { + "invalid_grouping_entity": { + "message": "Entity with ID {entity_id} can't be added to the WiiM multiroom. Is the entity a WiiM media player?" + }, + "missing_homeassistant_url": { + "message": "Failed to determine Home Assistant URL" + } + } +} diff --git a/homeassistant/components/wilight/manifest.json b/homeassistant/components/wilight/manifest.json index 7f7e16d55fbaac..702e5398dba9d1 100644 --- a/homeassistant/components/wilight/manifest.json +++ b/homeassistant/components/wilight/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@leofig-rj"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wilight", + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pywilight"], "requirements": ["pywilight==0.0.74"], diff --git a/homeassistant/components/window/__init__.py b/homeassistant/components/window/__init__.py new file mode 100644 index 00000000000000..b4577fd370e68b --- /dev/null +++ b/homeassistant/components/window/__init__.py @@ -0,0 +1,17 @@ +"""Integration for window triggers.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +DOMAIN = "window" +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/window/condition.py b/homeassistant/components/window/condition.py new file mode 100644 index 00000000000000..35fa26378b7075 --- /dev/null +++ b/homeassistant/components/window/condition.py @@ -0,0 +1,29 @@ +"""Provides conditions for windows.""" + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + CoverDeviceClass, + make_cover_is_closed_condition, + make_cover_is_open_condition, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.condition import Condition + +DEVICE_CLASSES_WINDOW: dict[str, str] = { + BINARY_SENSOR_DOMAIN: BinarySensorDeviceClass.WINDOW, + COVER_DOMAIN: CoverDeviceClass.WINDOW, +} + +CONDITIONS: dict[str, type[Condition]] = { + "is_closed": make_cover_is_closed_condition(device_classes=DEVICE_CLASSES_WINDOW), + "is_open": make_cover_is_open_condition(device_classes=DEVICE_CLASSES_WINDOW), +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the conditions for windows.""" + return CONDITIONS diff --git a/homeassistant/components/window/conditions.yaml b/homeassistant/components/window/conditions.yaml new file mode 100644 index 00000000000000..327fb2826a8d90 --- /dev/null +++ b/homeassistant/components/window/conditions.yaml @@ -0,0 +1,28 @@ +.condition_common_fields: &condition_common_fields + behavior: + required: true + default: any + selector: + select: + translation_key: condition_behavior + options: + - all + - any + +is_closed: + fields: *condition_common_fields + target: + entity: + - domain: binary_sensor + device_class: window + - domain: cover + device_class: window + +is_open: + fields: *condition_common_fields + target: + entity: + - domain: binary_sensor + device_class: window + - domain: cover + device_class: window diff --git a/homeassistant/components/window/icons.json b/homeassistant/components/window/icons.json new file mode 100644 index 00000000000000..b6873122170227 --- /dev/null +++ b/homeassistant/components/window/icons.json @@ -0,0 +1,18 @@ +{ + "conditions": { + "is_closed": { + "condition": "mdi:window-closed" + }, + "is_open": { + "condition": "mdi:window-open" + } + }, + "triggers": { + "closed": { + "trigger": "mdi:window-closed" + }, + "opened": { + "trigger": "mdi:window-open" + } + } +} diff --git a/homeassistant/components/window/manifest.json b/homeassistant/components/window/manifest.json new file mode 100644 index 00000000000000..f378cffc0c9077 --- /dev/null +++ b/homeassistant/components/window/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "window", + "name": "Window", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/window", + "integration_type": "system", + "quality_scale": "internal" +} diff --git a/homeassistant/components/window/strings.json b/homeassistant/components/window/strings.json new file mode 100644 index 00000000000000..b0b4d3f4aefa5d --- /dev/null +++ b/homeassistant/components/window/strings.json @@ -0,0 +1,68 @@ +{ + "common": { + "condition_behavior_description": "How the state should match on the targeted windows.", + "condition_behavior_name": "Behavior", + "trigger_behavior_description": "The behavior of the targeted windows to trigger on.", + "trigger_behavior_name": "Behavior" + }, + "conditions": { + "is_closed": { + "description": "Tests if one or more windows are closed.", + "fields": { + "behavior": { + "description": "[%key:component::window::common::condition_behavior_description%]", + "name": "[%key:component::window::common::condition_behavior_name%]" + } + }, + "name": "Window is closed" + }, + "is_open": { + "description": "Tests if one or more windows are open.", + "fields": { + "behavior": { + "description": "[%key:component::window::common::condition_behavior_description%]", + "name": "[%key:component::window::common::condition_behavior_name%]" + } + }, + "name": "Window is open" + } + }, + "selector": { + "condition_behavior": { + "options": { + "all": "All", + "any": "Any" + } + }, + "trigger_behavior": { + "options": { + "any": "Any", + "first": "First", + "last": "Last" + } + } + }, + "title": "Window", + "triggers": { + "closed": { + "description": "Triggers after one or more windows close.", + "fields": { + "behavior": { + "description": "[%key:component::window::common::trigger_behavior_description%]", + "name": "[%key:component::window::common::trigger_behavior_name%]" + } + }, + "name": "Window closed" + }, + "opened": { + "description": "Triggers after one or more windows open.", + "fields": { + "behavior": { + "description": "[%key:component::window::common::trigger_behavior_description%]", + "name": "[%key:component::window::common::trigger_behavior_name%]" + } + }, + "name": "Window opened" + } + } +} diff --git a/homeassistant/components/window/trigger.py b/homeassistant/components/window/trigger.py new file mode 100644 index 00000000000000..f504e2d115f524 --- /dev/null +++ b/homeassistant/components/window/trigger.py @@ -0,0 +1,30 @@ +"""Provides triggers for windows.""" + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + CoverDeviceClass, + make_cover_closed_trigger, + make_cover_opened_trigger, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.trigger import Trigger + +DEVICE_CLASSES_WINDOW: dict[str, str] = { + BINARY_SENSOR_DOMAIN: BinarySensorDeviceClass.WINDOW, + COVER_DOMAIN: CoverDeviceClass.WINDOW, +} + + +TRIGGERS: dict[str, type[Trigger]] = { + "opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_WINDOW), + "closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_WINDOW), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for windows.""" + return TRIGGERS diff --git a/homeassistant/components/window/triggers.yaml b/homeassistant/components/window/triggers.yaml new file mode 100644 index 00000000000000..4d770a85d2ca58 --- /dev/null +++ b/homeassistant/components/window/triggers.yaml @@ -0,0 +1,29 @@ +.trigger_common_fields: &trigger_common_fields + behavior: + required: true + default: any + selector: + select: + translation_key: trigger_behavior + options: + - first + - last + - any + +closed: + fields: *trigger_common_fields + target: + entity: + - domain: binary_sensor + device_class: window + - domain: cover + device_class: window + +opened: + fields: *trigger_common_fields + target: + entity: + - domain: binary_sensor + device_class: window + - domain: cover + device_class: window diff --git a/homeassistant/components/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py index 8cc4c53a479e44..84d032dec462f3 100644 --- a/homeassistant/components/wirelesstag/__init__.py +++ b/homeassistant/components/wirelesstag/__init__.py @@ -1,10 +1,14 @@ """Support for Wireless Sensor Tags.""" +from __future__ import annotations + import logging +from typing import TYPE_CHECKING from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol from wirelesstagpy import SensorTag, WirelessTags +from wirelesstagpy.binaryevent import BinaryEvent from wirelesstagpy.exceptions import WirelessTagsException from homeassistant.components import persistent_notification @@ -21,6 +25,9 @@ WIRELESSTAG_DATA, ) +if TYPE_CHECKING: + from .switch import WirelessTagSwitch + _LOGGER = logging.getLogger(__name__) NOTIFICATION_ID = "wirelesstag_notification" @@ -56,22 +63,24 @@ def load_tags(self) -> dict[str, SensorTag]: self.tags = self.api.load_tags() return self.tags - def arm(self, switch): + def arm(self, switch: WirelessTagSwitch) -> None: """Arm entity sensor monitoring.""" func_name = f"arm_{switch.entity_description.key}" if (arm_func := getattr(self.api, func_name)) is not None: arm_func(switch.tag_id, switch.tag_manager_mac) - def disarm(self, switch): + def disarm(self, switch: WirelessTagSwitch) -> None: """Disarm entity sensor monitoring.""" func_name = f"disarm_{switch.entity_description.key}" if (disarm_func := getattr(self.api, func_name)) is not None: disarm_func(switch.tag_id, switch.tag_manager_mac) - def start_monitoring(self): + def start_monitoring(self) -> None: """Start monitoring push events.""" - def push_callback(tags_spec, event_spec): + def push_callback( + tags_spec: dict[str, SensorTag], event_spec: dict[str, list[BinaryEvent]] + ) -> None: """Handle push update.""" _LOGGER.debug( "Push notification arrived: %s, events: %s", tags_spec, event_spec diff --git a/homeassistant/components/wirelesstag/binary_sensor.py b/homeassistant/components/wirelesstag/binary_sensor.py index 430c4c07bde015..b153f43109efb0 100644 --- a/homeassistant/components/wirelesstag/binary_sensor.py +++ b/homeassistant/components/wirelesstag/binary_sensor.py @@ -77,8 +77,8 @@ def __init__( """Initialize a binary sensor for a Wireless Sensor Tags.""" super().__init__(api, tag) self._sensor_type = sensor_type - self._name = f"{self._tag.name} {self.event.human_readable_name}" self._attr_device_class = SENSOR_TYPES[sensor_type] + self._attr_name = f"{self._tag.name} {self.event.human_readable_name}" self._attr_unique_id = f"{self._uuid}_{self._sensor_type}" async def async_added_to_hass(self) -> None: @@ -95,7 +95,7 @@ async def async_added_to_hass(self) -> None: ) @property - def is_on(self): + def is_on(self) -> bool: """Return True if the binary sensor is on.""" return self._state == STATE_ON @@ -117,7 +117,7 @@ def updated_state_value(self): return self.principal_value @callback - def _on_binary_event_callback(self, new_tag): + def _on_binary_event_callback(self, new_tag: SensorTag) -> None: """Update state from arrived push notification.""" self._tag = new_tag self._state = self.updated_state_value() diff --git a/homeassistant/components/wirelesstag/entity.py b/homeassistant/components/wirelesstag/entity.py index 73b13cdc39710f..c6253fef38079e 100644 --- a/homeassistant/components/wirelesstag/entity.py +++ b/homeassistant/components/wirelesstag/entity.py @@ -1,6 +1,9 @@ """Support for Wireless Sensor Tags.""" import logging +from typing import Any + +from wirelesstagpy import SensorTag from homeassistant.const import ( ATTR_BATTERY_LEVEL, @@ -11,6 +14,8 @@ ) from homeassistant.helpers.entity import Entity +from . import WirelessTagPlatform + _LOGGER = logging.getLogger(__name__) @@ -25,21 +30,16 @@ class WirelessTagBaseSensor(Entity): """Base class for HA implementation for Wireless Sensor Tag.""" - def __init__(self, api, tag): + def __init__(self, api: WirelessTagPlatform, tag: SensorTag) -> None: """Initialize a base sensor for Wireless Sensor Tag platform.""" self._api = api self._tag = tag self._uuid = self._tag.uuid self.tag_id = self._tag.tag_id self.tag_manager_mac = self._tag.tag_manager_mac - self._name = self._tag.name + self._attr_name = self._tag.name self._state = None - @property - def name(self): - """Return the name of the sensor.""" - return self._name - @property def principal_value(self): """Return base value. @@ -78,7 +78,7 @@ def update(self) -> None: self._state = self.updated_state_value() @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return { ATTR_BATTERY_LEVEL: int(self._tag.battery_remaining * 100), diff --git a/homeassistant/components/wirelesstag/sensor.py b/homeassistant/components/wirelesstag/sensor.py index 913e1dbf7a0613..33ea005c56ac40 100644 --- a/homeassistant/components/wirelesstag/sensor.py +++ b/homeassistant/components/wirelesstag/sensor.py @@ -5,6 +5,7 @@ import logging import voluptuous as vol +from wirelesstagpy import SensorTag from homeassistant.components.sensor import ( PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, @@ -20,6 +21,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import WirelessTagPlatform from .const import DOMAIN, SIGNAL_TAG_UPDATE, WIRELESSTAG_DATA from .entity import WirelessTagBaseSensor from .util import async_migrate_unique_id @@ -97,13 +99,18 @@ class WirelessTagSensor(WirelessTagBaseSensor, SensorEntity): entity_description: SensorEntityDescription - def __init__(self, api, tag, description): + def __init__( + self, + api: WirelessTagPlatform, + tag: SensorTag, + description: SensorEntityDescription, + ) -> None: """Initialize a WirelessTag sensor.""" super().__init__(api, tag) self._sensor_type = description.key self.entity_description = description - self._name = self._tag.name + self._attr_name = self._tag.name self._attr_unique_id = f"{self._uuid}_{self._sensor_type}" # I want to see entity_id as: @@ -148,7 +155,7 @@ def _sensor(self): return self._tag.sensor[self._sensor_type] @callback - def _update_tag_info_callback(self, new_tag): + def _update_tag_info_callback(self, new_tag: SensorTag) -> None: """Handle push notification sent by tag manager.""" _LOGGER.debug("Entity to update state: %s with new tag: %s", self, new_tag) self._tag = new_tag diff --git a/homeassistant/components/wirelesstag/switch.py b/homeassistant/components/wirelesstag/switch.py index 53e28f9103d360..6743138fb99ab3 100644 --- a/homeassistant/components/wirelesstag/switch.py +++ b/homeassistant/components/wirelesstag/switch.py @@ -5,6 +5,7 @@ from typing import Any import voluptuous as vol +from wirelesstagpy import SensorTag from homeassistant.components.switch import ( PLATFORM_SCHEMA as SWITCH_PLATFORM_SCHEMA, @@ -17,6 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import WirelessTagPlatform from .const import WIRELESSTAG_DATA from .entity import WirelessTagBaseSensor from .util import async_migrate_unique_id @@ -82,11 +84,16 @@ async def async_setup_platform( class WirelessTagSwitch(WirelessTagBaseSensor, SwitchEntity): """A switch implementation for Wireless Sensor Tags.""" - def __init__(self, api, tag, description: SwitchEntityDescription) -> None: + def __init__( + self, + api: WirelessTagPlatform, + tag: SensorTag, + description: SwitchEntityDescription, + ) -> None: """Initialize a switch for Wireless Sensor Tag.""" super().__init__(api, tag) self.entity_description = description - self._name = f"{self._tag.name} {description.name}" + self._attr_name = f"{self._tag.name} {description.name}" self._attr_unique_id = f"{self._uuid}_{description.key}" def turn_on(self, **kwargs: Any) -> None: @@ -98,7 +105,7 @@ def turn_off(self, **kwargs: Any) -> None: self._api.disarm(self) @property - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return True if entity is on.""" return self._state diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index bea4af3627abbd..31f6e61a463d33 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -214,6 +214,7 @@ class WithingsWebhookManager: """Manager that manages the Withings webhooks.""" _webhooks_registered = False + _webhook_url_invalid = False _register_lock = asyncio.Lock() def __init__(self, hass: HomeAssistant, entry: WithingsConfigEntry) -> None: @@ -260,16 +261,20 @@ async def register_webhook( ) url = URL(webhook_url) if url.scheme != "https": - LOGGER.warning( - "Webhook not registered - HTTPS is required. " - "See https://www.home-assistant.io/integrations/withings/#webhook-requirements" - ) + if not self._webhook_url_invalid: + LOGGER.warning( + "Webhook not registered - HTTPS is required. " + "See https://www.home-assistant.io/integrations/withings/#webhook-requirements" + ) + self._webhook_url_invalid = True return if url.port != 443: - LOGGER.warning( - "Webhook not registered - port 443 is required. " - "See https://www.home-assistant.io/integrations/withings/#webhook-requirements" - ) + if not self._webhook_url_invalid: + LOGGER.warning( + "Webhook not registered - port 443 is required. " + "See https://www.home-assistant.io/integrations/withings/#webhook-requirements" + ) + self._webhook_url_invalid = True return webhook_name = "Withings" diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index 232997da054930..26330357193c9a 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -11,6 +11,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/withings", + "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["aiowithings"], "requirements": ["aiowithings==3.1.6"] diff --git a/homeassistant/components/wiz/__init__.py b/homeassistant/components/wiz/__init__.py index 39be4d9a3878fd..f66df15f6b40d6 100644 --- a/homeassistant/components/wiz/__init__.py +++ b/homeassistant/components/wiz/__init__.py @@ -2,23 +2,19 @@ from __future__ import annotations -from datetime import timedelta import logging from typing import Any from pywizlight import PilotParser, wizlight from pywizlight.bulb import PIR_SOURCE -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( DISCOVER_SCAN_TIMEOUT, @@ -26,12 +22,9 @@ DOMAIN, SIGNAL_WIZ_PIR, WIZ_CONNECT_EXCEPTIONS, - WIZ_EXCEPTIONS, ) +from .coordinator import WizConfigEntry, WizCoordinator, WizData from .discovery import async_discover_devices, async_trigger_discovery -from .models import WizData - -type WizConfigEntry = ConfigEntry[WizData] _LOGGER = logging.getLogger(__name__) @@ -44,8 +37,6 @@ Platform.SWITCH, ] -REQUEST_REFRESH_DELAY = 0.35 - CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -90,30 +81,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WizConfigEntry) -> bool: "Found bulb {bulb.mac} at {ip_address}, expected {entry.unique_id}" ) - async def _async_update() -> float | None: - """Update the WiZ device.""" - try: - await bulb.updateState() - if bulb.power_monitoring is not False: - power: float | None = await bulb.get_power() - return power - except WIZ_EXCEPTIONS as ex: - raise UpdateFailed(f"Failed to update device at {ip_address}: {ex}") from ex - return None - - coordinator = DataUpdateCoordinator( - hass=hass, - logger=_LOGGER, - config_entry=entry, - name=entry.title, - update_interval=timedelta(seconds=15), - update_method=_async_update, - # We don't want an immediate refresh since the device - # takes a moment to reflect the state change - request_refresh_debouncer=Debouncer( - hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False - ), - ) + coordinator = WizCoordinator(hass, entry, bulb) try: await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/wiz/binary_sensor.py b/homeassistant/components/wiz/binary_sensor.py index 385e6827d7769d..9f5e548d5523e9 100644 --- a/homeassistant/components/wiz/binary_sensor.py +++ b/homeassistant/components/wiz/binary_sensor.py @@ -16,10 +16,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import WizConfigEntry from .const import DOMAIN, SIGNAL_WIZ_PIR +from .coordinator import WizConfigEntry, WizData from .entity import WizEntity -from .models import WizData OCCUPANCY_UNIQUE_ID = "{}_occupancy" diff --git a/homeassistant/components/wiz/coordinator.py b/homeassistant/components/wiz/coordinator.py new file mode 100644 index 00000000000000..4ff125934a2302 --- /dev/null +++ b/homeassistant/components/wiz/coordinator.py @@ -0,0 +1,71 @@ +"""DataUpdateCoordinator for the WiZ Platform integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging + +from pywizlight import wizlight + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import WIZ_EXCEPTIONS + +_LOGGER = logging.getLogger(__name__) + +REQUEST_REFRESH_DELAY = 0.35 + + +type WizConfigEntry = ConfigEntry[WizData] + + +@dataclass +class WizData: + """Data for the wiz integration.""" + + coordinator: WizCoordinator + bulb: wizlight + scenes: list + + +class WizCoordinator(DataUpdateCoordinator[float | None]): + """Class to manage fetching WiZ data.""" + + config_entry: WizConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: WizConfigEntry, + bulb: wizlight, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=entry.title, + update_interval=timedelta(seconds=15), + # We don't want an immediate refresh since the device + # takes a moment to reflect the state change + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False + ), + ) + self._bulb = bulb + + async def _async_update_data(self) -> float | None: + """Update the WiZ device.""" + ip_address = self._bulb.ip + try: + await self._bulb.updateState() + if self._bulb.power_monitoring is not False: + power: float | None = await self._bulb.get_power() + return power + except WIZ_EXCEPTIONS as ex: + raise UpdateFailed(f"Failed to update device at {ip_address}: {ex}") from ex + return None diff --git a/homeassistant/components/wiz/diagnostics.py b/homeassistant/components/wiz/diagnostics.py index c58751c7fc036e..7aa5940b7caa79 100644 --- a/homeassistant/components/wiz/diagnostics.py +++ b/homeassistant/components/wiz/diagnostics.py @@ -7,7 +7,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant -from . import WizConfigEntry +from .coordinator import WizConfigEntry TO_REDACT = {"roomId", "homeId"} diff --git a/homeassistant/components/wiz/entity.py b/homeassistant/components/wiz/entity.py index e7a95234e160b6..9a32b2a8ad9dc7 100644 --- a/homeassistant/components/wiz/entity.py +++ b/homeassistant/components/wiz/entity.py @@ -11,15 +11,12 @@ from homeassistant.core import callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import Entity, ToggleEntity -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .models import WizData +from .coordinator import WizCoordinator, WizData -class WizEntity(CoordinatorEntity[DataUpdateCoordinator[float | None]], Entity): +class WizEntity(CoordinatorEntity[WizCoordinator], Entity): """Representation of WiZ entity.""" _attr_has_entity_name = True diff --git a/homeassistant/components/wiz/fan.py b/homeassistant/components/wiz/fan.py index f826ee80b8b724..888a72f14ece84 100644 --- a/homeassistant/components/wiz/fan.py +++ b/homeassistant/components/wiz/fan.py @@ -21,9 +21,8 @@ ranged_value_to_percentage, ) -from . import WizConfigEntry +from .coordinator import WizConfigEntry, WizData from .entity import WizEntity -from .models import WizData PRESET_MODE_BREEZE = "breeze" diff --git a/homeassistant/components/wiz/light.py b/homeassistant/components/wiz/light.py index 8a6de65cf73c09..713849514a4d7e 100644 --- a/homeassistant/components/wiz/light.py +++ b/homeassistant/components/wiz/light.py @@ -22,9 +22,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import WizConfigEntry +from .coordinator import WizConfigEntry, WizData from .entity import WizToggleEntity -from .models import WizData RGB_WHITE_CHANNELS_COLOR_MODE = {1: ColorMode.RGBW, 2: ColorMode.RGBWW} diff --git a/homeassistant/components/wiz/manifest.json b/homeassistant/components/wiz/manifest.json index 57671ecd007071..f76bc745af5cde 100644 --- a/homeassistant/components/wiz/manifest.json +++ b/homeassistant/components/wiz/manifest.json @@ -25,6 +25,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/wiz", + "integration_type": "device", "iot_class": "local_push", "requirements": ["pywizlight==0.6.3"] } diff --git a/homeassistant/components/wiz/models.py b/homeassistant/components/wiz/models.py deleted file mode 100644 index 125a8cfa73b22b..00000000000000 --- a/homeassistant/components/wiz/models.py +++ /dev/null @@ -1,18 +0,0 @@ -"""WiZ integration models.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from pywizlight import wizlight - -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - - -@dataclass -class WizData: - """Data for the wiz integration.""" - - coordinator: DataUpdateCoordinator[float | None] - bulb: wizlight - scenes: list diff --git a/homeassistant/components/wiz/number.py b/homeassistant/components/wiz/number.py index 0c8ee3f2bf4e86..e9b5125d200f9a 100644 --- a/homeassistant/components/wiz/number.py +++ b/homeassistant/components/wiz/number.py @@ -17,9 +17,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import WizConfigEntry +from .coordinator import WizConfigEntry, WizData from .entity import WizEntity -from .models import WizData @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/wiz/sensor.py b/homeassistant/components/wiz/sensor.py index 217dae9e8fb00a..1cafa58996c270 100644 --- a/homeassistant/components/wiz/sensor.py +++ b/homeassistant/components/wiz/sensor.py @@ -16,9 +16,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import WizConfigEntry +from .coordinator import WizConfigEntry, WizData from .entity import WizEntity -from .models import WizData SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( diff --git a/homeassistant/components/wiz/switch.py b/homeassistant/components/wiz/switch.py index a57834bc18dfa4..688adc0caa3bfd 100644 --- a/homeassistant/components/wiz/switch.py +++ b/homeassistant/components/wiz/switch.py @@ -11,9 +11,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import WizConfigEntry +from .coordinator import WizConfigEntry, WizData from .entity import WizToggleEntity -from .models import WizData async def async_setup_entry( diff --git a/homeassistant/components/wled/manifest.json b/homeassistant/components/wled/manifest.json index 326008ae1af4bf..b14c5df25ef35b 100644 --- a/homeassistant/components/wled/manifest.json +++ b/homeassistant/components/wled/manifest.json @@ -1,11 +1,12 @@ { "domain": "wled", "name": "WLED", - "codeowners": ["@frenck"], + "codeowners": ["@frenck", "@mik-laj"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wled", "integration_type": "device", "iot_class": "local_push", + "quality_scale": "platinum", "requirements": ["wled==0.21.0"], "zeroconf": ["_wled._tcp.local."] } diff --git a/homeassistant/components/wled/quality_scale.yaml b/homeassistant/components/wled/quality_scale.yaml index 11a59bcc6d97f9..c3185f05dce514 100644 --- a/homeassistant/components/wled/quality_scale.yaml +++ b/homeassistant/components/wled/quality_scale.yaml @@ -24,10 +24,11 @@ rules: unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done - docs-installation-parameters: todo + docs-installation-parameters: done + entity-unavailable: done integration-owner: done log-when-unavailable: done parallel-updates: done @@ -41,25 +42,19 @@ rules: diagnostics: done discovery-update-info: done discovery: done - docs-data-update: todo + docs-data-update: done docs-examples: done - docs-known-limitations: - status: todo - comment: | - Analog RGBCCT Strip are poor supported by HA. - See: https://github.com/home-assistant/core/issues/123614 - docs-supported-devices: todo + 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: | This integration has a fixed single device. entity-category: done - entity-device-class: - status: todo - comment: Led count could receive unit of measurement + entity-device-class: done entity-disabled-by-default: done entity-translations: done exception-translations: done diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index 9719406472e638..aa4303c6709413 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -89,7 +89,8 @@ "name": "Free memory" }, "info_leds_count": { - "name": "LED count" + "name": "LED count", + "unit_of_measurement": "LEDs" }, "info_leds_max_power": { "name": "Max current" diff --git a/homeassistant/components/wolflink/__init__.py b/homeassistant/components/wolflink/__init__.py index fd44a454164766..3fb733e650be72 100644 --- a/homeassistant/components/wolflink/__init__.py +++ b/homeassistant/components/wolflink/__init__.py @@ -1,11 +1,9 @@ """The Wolf SmartSet Service integration.""" -from datetime import timedelta import logging from httpx import RequestError -from wolf_comm.token_auth import InvalidAuth -from wolf_comm.wolf_client import FetchFailed, ParameterReadError, WolfClient +from wolf_comm.wolf_client import FetchFailed, WolfClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform @@ -13,7 +11,6 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.httpx_client import create_async_httpx_client -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( COORDINATOR, @@ -23,6 +20,7 @@ DOMAIN, PARAMETERS, ) +from .coordinator import WolfLinkCoordinator, fetch_parameters _LOGGER = logging.getLogger(__name__) @@ -37,7 +35,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device_name = entry.data[DEVICE_NAME] device_id = entry.data[DEVICE_ID] gateway_id = entry.data[DEVICE_GATEWAY] - refetch_parameters = False _LOGGER.debug( "Setting up wolflink integration for device: %s (ID: %s, gateway: %s)", device_name, @@ -53,57 +50,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: parameters = await fetch_parameters_init(wolf_client, gateway_id, device_id) - async def async_update_data(): - """Update all stored entities for Wolf SmartSet.""" - try: - nonlocal refetch_parameters - nonlocal parameters - if not await wolf_client.fetch_system_state_list(device_id, gateway_id): - refetch_parameters = True - raise UpdateFailed( - "Could not fetch values from server because device is Offline." - ) - if refetch_parameters: - parameters = await fetch_parameters(wolf_client, gateway_id, device_id) - hass.data[DOMAIN][entry.entry_id][PARAMETERS] = parameters - refetch_parameters = False - values = { - v.value_id: v.value - for v in await wolf_client.fetch_value( - gateway_id, device_id, parameters - ) - } - return { - parameter.parameter_id: ( - parameter.value_id, - values[parameter.value_id], - ) - for parameter in parameters - if parameter.value_id in values - } - except RequestError as exception: - raise UpdateFailed( - f"Error communicating with API: {exception}" - ) from exception - except FetchFailed as exception: - raise UpdateFailed( - f"Could not fetch values from server due to: {exception}" - ) from exception - except ParameterReadError as exception: - refetch_parameters = True - raise UpdateFailed( - "Could not fetch values for parameter. Refreshing value IDs." - ) from exception - except InvalidAuth as exception: - raise UpdateFailed("Invalid authentication during update.") from exception - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name=DOMAIN, - update_method=async_update_data, - update_interval=timedelta(seconds=60), + coordinator = WolfLinkCoordinator( + hass, entry, wolf_client, parameters, gateway_id, device_id ) await coordinator.async_refresh() @@ -154,15 +102,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def fetch_parameters(client: WolfClient, gateway_id: int, device_id: int): - """Fetch all available parameters with usage of WolfClient. - - By default Reglertyp entity is removed because API will not provide value for this parameter. - """ - fetched_parameters = await client.fetch_parameters(gateway_id, device_id) - return [param for param in fetched_parameters if param.name != "Reglertyp"] - - async def fetch_parameters_init(client: WolfClient, gateway_id: int, device_id: int): """Fetch all available parameters with usage of WolfClient but handles all exceptions and results in ConfigEntryNotReady.""" try: diff --git a/homeassistant/components/wolflink/coordinator.py b/homeassistant/components/wolflink/coordinator.py new file mode 100644 index 00000000000000..24e557a9bf5002 --- /dev/null +++ b/homeassistant/components/wolflink/coordinator.py @@ -0,0 +1,102 @@ +"""DataUpdateCoordinator for the Wolf SmartSet Service integration.""" + +from datetime import timedelta +import logging + +from httpx import RequestError +from wolf_comm.models import Parameter +from wolf_comm.token_auth import InvalidAuth +from wolf_comm.wolf_client import FetchFailed, ParameterReadError, WolfClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class WolfLinkCoordinator(DataUpdateCoordinator[dict[int, tuple[int, str]]]): + """Class to manage fetching Wolf SmartSet data.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + wolf_client: WolfClient, + parameters: list[Parameter], + gateway_id: int, + device_id: int, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=timedelta(seconds=60), + ) + self._wolf_client = wolf_client + self._parameters = parameters + self._gateway_id = gateway_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._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._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 + ) + } + return { + parameter.parameter_id: ( + parameter.value_id, + values[parameter.value_id], + ) + for parameter in self._parameters + if parameter.value_id in values + } + except RequestError as exception: + raise UpdateFailed( + f"Error communicating with API: {exception}" + ) from exception + except FetchFailed as exception: + raise UpdateFailed( + f"Could not fetch values from server due to: {exception}" + ) from exception + except ParameterReadError as exception: + self._refetch_parameters = True + raise UpdateFailed( + "Could not fetch values for parameter. Refreshing value IDs." + ) from exception + except InvalidAuth as exception: + raise UpdateFailed("Invalid authentication during update.") from exception + + +async def fetch_parameters( + client: WolfClient, gateway_id: int, device_id: int +) -> list[Parameter]: + """Fetch all available parameters with usage of WolfClient. + + By default Reglertyp entity is removed because API will not provide value for this parameter. + """ + fetched_parameters = await client.fetch_parameters(gateway_id, device_id) + return [param for param in fetched_parameters if param.name != "Reglertyp"] diff --git a/homeassistant/components/wolflink/manifest.json b/homeassistant/components/wolflink/manifest.json index 5f3a6366fe18d5..11c0f9b5bb1d89 100644 --- a/homeassistant/components/wolflink/manifest.json +++ b/homeassistant/components/wolflink/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@adamkrol93", "@mtielen"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wolflink", + "integration_type": "device", "iot_class": "cloud_polling", "loggers": ["wolf_comm"], "requirements": ["wolf-comm==0.0.23"] diff --git a/homeassistant/components/wolflink/sensor.py b/homeassistant/components/wolflink/sensor.py index 9380c28de89f4b..0205ce793edf13 100644 --- a/homeassistant/components/wolflink/sensor.py +++ b/homeassistant/components/wolflink/sensor.py @@ -44,6 +44,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import COORDINATOR, DEVICE_ID, DOMAIN, MANUFACTURER, PARAMETERS, STATES +from .coordinator import WolfLinkCoordinator def get_listitem_resolve_state(wolf_object, state): @@ -150,16 +151,16 @@ async def async_setup_entry( async_add_entities(entities, True) -class WolfLinkSensor(CoordinatorEntity, SensorEntity): +class WolfLinkSensor(CoordinatorEntity[WolfLinkCoordinator], SensorEntity): """Base class for all Wolf entities.""" entity_description: WolflinkSensorEntityDescription def __init__( self, - coordinator, + coordinator: WolfLinkCoordinator, wolf_object: Parameter, - device_id: str, + device_id: int, description: WolflinkSensorEntityDescription, ) -> None: """Initialize.""" @@ -168,7 +169,7 @@ def __init__( self.wolf_object = wolf_object self._attr_name = wolf_object.name self._attr_unique_id = f"{device_id}:{wolf_object.parameter_id}" - self._state = None + self._state: str | None = None self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, str(device_id))}, configuration_url="https://www.wolf-smartset.com/", diff --git a/homeassistant/components/worldclock/manifest.json b/homeassistant/components/worldclock/manifest.json index bc7ee3cd9390e2..d31bba145d52b6 100644 --- a/homeassistant/components/worldclock/manifest.json +++ b/homeassistant/components/worldclock/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@fabaff"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/worldclock", + "integration_type": "service", "iot_class": "local_push", "quality_scale": "internal" } diff --git a/homeassistant/components/worldtidesinfo/sensor.py b/homeassistant/components/worldtidesinfo/sensor.py index 1a64954bb4a358..b38b3d4f602ca0 100644 --- a/homeassistant/components/worldtidesinfo/sensor.py +++ b/homeassistant/components/worldtidesinfo/sensor.py @@ -5,6 +5,7 @@ from datetime import timedelta import logging import time +from typing import Any import requests import voluptuous as vol @@ -81,7 +82,7 @@ def name(self): return self._name @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of this device.""" attr = {} diff --git a/homeassistant/components/worxlandroid/sensor.py b/homeassistant/components/worxlandroid/sensor.py index 72c44d200a06ae..2b10ed386323fd 100644 --- a/homeassistant/components/worxlandroid/sensor.py +++ b/homeassistant/components/worxlandroid/sensor.py @@ -28,7 +28,7 @@ PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PIN): vol.All(vol.Coerce(str), vol.Match(r"\d{4}")), + vol.Required(CONF_PIN): cv.string, vol.Optional(CONF_ALLOW_UNREACHABLE, default=True): cv.boolean, vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, } diff --git a/homeassistant/components/ws66i/manifest.json b/homeassistant/components/ws66i/manifest.json index c465a9f9f37794..9b20a2ca5ddd25 100644 --- a/homeassistant/components/ws66i/manifest.json +++ b/homeassistant/components/ws66i/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@ssaenger"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ws66i", + "integration_type": "device", "iot_class": "local_polling", "requirements": ["pyws66i==1.1"] } diff --git a/homeassistant/components/ws66i/media_player.py b/homeassistant/components/ws66i/media_player.py index fb8ba5ae99604f..36b199a1c9c064 100644 --- a/homeassistant/components/ws66i/media_player.py +++ b/homeassistant/components/ws66i/media_player.py @@ -55,6 +55,7 @@ class Ws66iZone(CoordinatorEntity[Ws66iDataUpdateCoordinator], MediaPlayerEntity | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.SELECT_SOURCE ) + _attr_volume_step = 1 / MAX_VOL def __init__( self, @@ -147,20 +148,6 @@ async def async_set_volume_level(self, volume: float) -> None: await self.hass.async_add_executor_job(self._set_volume, int(volume * MAX_VOL)) self._async_update_attrs_write_ha_state() - async def async_volume_up(self) -> None: - """Volume up the media player.""" - await self.hass.async_add_executor_job( - self._set_volume, min(self._status.volume + 1, MAX_VOL) - ) - self._async_update_attrs_write_ha_state() - - async def async_volume_down(self) -> None: - """Volume down media player.""" - await self.hass.async_add_executor_job( - self._set_volume, max(self._status.volume - 1, 0) - ) - self._async_update_attrs_write_ha_state() - def _set_volume(self, volume: int) -> None: """Set the volume of the media player.""" # Can't set a new volume level when this zone is muted. diff --git a/homeassistant/components/wyoming/conversation.py b/homeassistant/components/wyoming/conversation.py index 988cf3c9045762..70d0ddc3bb6ce3 100644 --- a/homeassistant/components/wyoming/conversation.py +++ b/homeassistant/components/wyoming/conversation.py @@ -1,6 +1,7 @@ """Support for Wyoming intent recognition services.""" import logging +from typing import Literal from wyoming.asr import Transcript from wyoming.client import AsyncTcpClient @@ -10,6 +11,7 @@ from homeassistant.components import conversation from homeassistant.config_entries import ConfigEntry +from homeassistant.const import MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -89,8 +91,11 @@ def __init__( self._attr_unique_id = f"{config_entry.entry_id}-conversation" @property - def supported_languages(self) -> list[str]: + def supported_languages(self) -> list[str] | Literal["*"]: """Return a list of supported languages.""" + if not self._supported_languages: + return MATCH_ALL + return self._supported_languages async def async_process( @@ -100,11 +105,17 @@ async def async_process( conversation_id = user_input.conversation_id or ulid_util.ulid_now() intent_response = intent.IntentResponse(language=user_input.language) + context = {"conversation_id": conversation_id} + if user_input.satellite_id: + context["satellite_id"] = user_input.satellite_id + try: async with AsyncTcpClient(self.service.host, self.service.port) as client: await client.write_event( Transcript( - user_input.text, context={"conversation_id": conversation_id} + user_input.text, + context=context, + language=user_input.language, ).event() ) @@ -138,6 +149,8 @@ async def async_process( intent_slots, text_input=user_input.text, language=user_input.language, + satellite_id=user_input.satellite_id, + device_id=user_input.device_id, ) if (not intent_response.speech) and recognized_intent.text: diff --git a/homeassistant/components/wyoming/strings.json b/homeassistant/components/wyoming/strings.json index 3594ecdf188fc6..cdcd95f327f340 100644 --- a/homeassistant/components/wyoming/strings.json +++ b/homeassistant/components/wyoming/strings.json @@ -55,6 +55,12 @@ "preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]" } }, + "pipeline_n": { + "name": "[%key:component::assist_pipeline::entity::select::pipeline_n::name%]", + "state": { + "preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]" + } + }, "vad_sensitivity": { "name": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::name%]", "state": { diff --git a/homeassistant/components/x10/light.py b/homeassistant/components/x10/light.py index fbdebe116577c0..035b306888cfbe 100644 --- a/homeassistant/components/x10/light.py +++ b/homeassistant/components/x10/light.py @@ -63,48 +63,31 @@ def setup_platform( class X10Light(LightEntity): """Representation of an X10 Light.""" + _attr_brightness: int _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} def __init__(self, light, is_cm11a): """Initialize an X10 Light.""" - self._name = light["name"] + self._attr_name = light["name"] self._id = light["id"] - self._brightness = 0 - self._state = False + self._attr_brightness = 0 + self._attr_is_on = False self._is_cm11a = is_cm11a - @property - def name(self): - """Return the display name of this light.""" - return self._name - - @property - def brightness(self): - """Return the brightness of the light, scaled to base class 0..255. - - This needs to be scaled from 0..x for use with X10 dimmers. - """ - return self._brightness - - def normalize_x10_brightness(self, brightness: float) -> float: + def normalize_x10_brightness(self, brightness: float) -> int: """Return calculated brightness values.""" return int((brightness / 255) * 32) - @property - def is_on(self): - """Return true if light is on.""" - return self._state - def turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" - old_brightness = self._brightness + old_brightness = self._attr_brightness if old_brightness == 0: # Dim down from max if applicable, also avoids a "dim" command if an "on" is more appropriate old_brightness = 255 - self._brightness = kwargs.get(ATTR_BRIGHTNESS, 255) + self._attr_brightness = kwargs.get(ATTR_BRIGHTNESS, 255) brightness_diff = self.normalize_x10_brightness( - self._brightness + self._attr_brightness ) - self.normalize_x10_brightness(old_brightness) command_suffix = "" # heyu has quite a messy command structure - we'll just deal with it here @@ -121,7 +104,7 @@ def turn_on(self, **kwargs: Any) -> None: command_suffix = f" {brightness_diff}" else: if self._is_cm11a: - if self._state: + if self._attr_is_on: command_prefix = "dim" else: command_prefix = "dimb" @@ -129,7 +112,7 @@ def turn_on(self, **kwargs: Any) -> None: command_prefix = "fdim" command_suffix = f" {-brightness_diff}" x10_command(f"{command_prefix} {self._id}{command_suffix}") - self._state = True + self._attr_is_on = True def turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" @@ -137,13 +120,13 @@ def turn_off(self, **kwargs: Any) -> None: x10_command(f"off {self._id}") else: x10_command(f"foff {self._id}") - self._brightness = 0 - self._state = False + self._attr_brightness = 0 + self._attr_is_on = False def update(self) -> None: """Fetch update state.""" if self._is_cm11a: - self._state = bool(get_unit_status(self._id)) + self._attr_is_on = bool(get_unit_status(self._id)) else: # Not supported on CM17A pass diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py index 9b9a61a5cc4cc0..f9f06b503d71a8 100644 --- a/homeassistant/components/xbox/__init__.py +++ b/homeassistant/components/xbox/__init__.py @@ -111,7 +111,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: XboxConfigEntry) -> bo # Migrate unique_id from `xbox` to account xuid and # change generic entry name to user's gamertag try: - own = await client.people.get_friends_by_xuid(client.xuid) + own = await client.people.get_friend_by_xuid(client.xuid) except TimeoutException as e: raise ConfigEntryNotReady( translation_domain=DOMAIN, diff --git a/homeassistant/components/xbox/api.py b/homeassistant/components/xbox/api.py index b772cae5912906..a3d3a287c96e79 100644 --- a/homeassistant/components/xbox/api.py +++ b/homeassistant/components/xbox/api.py @@ -1,13 +1,17 @@ """API for xbox bound to Home Assistant OAuth.""" -from http import HTTPStatus - -from aiohttp.client_exceptions import ClientResponseError -from httpx import AsyncClient +from aiohttp import ClientError +from httpx import AsyncClient, HTTPStatusError, RequestError from pythonxbox.authentication.manager import AuthenticationManager from pythonxbox.authentication.models import OAuth2TokenResponse +from pythonxbox.common.exceptions import AuthenticationException -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + OAuth2TokenRequestReauthError, + OAuth2TokenRequestTransientError, +) from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from homeassistant.util.dt import utc_from_timestamp @@ -30,16 +34,12 @@ async def refresh_tokens(self) -> None: if not self._oauth_session.valid_token: try: await self._oauth_session.async_ensure_token_valid() - except ClientResponseError as e: - if ( - HTTPStatus.BAD_REQUEST - <= e.status - < HTTPStatus.INTERNAL_SERVER_ERROR - ): - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="auth_exception", - ) from e + except OAuth2TokenRequestReauthError as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_exception", + ) from e + except (OAuth2TokenRequestTransientError, ClientError) as e: raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="request_exception", @@ -47,7 +47,18 @@ async def refresh_tokens(self) -> None: self.oauth = self._get_oauth_token() # This will skip the OAuth refresh and only refresh User and XSTS tokens - await super().refresh_tokens() + try: + await super().refresh_tokens() + except AuthenticationException as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_exception", + ) from e + except (RequestError, HTTPStatusError) as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="request_exception", + ) from e def _get_oauth_token(self) -> OAuth2TokenResponse: tokens = {**self._oauth_session.token} diff --git a/homeassistant/components/xbox/config_flow.py b/homeassistant/components/xbox/config_flow.py index 5ca58210f18510..156055559206ab 100644 --- a/homeassistant/components/xbox/config_flow.py +++ b/homeassistant/components/xbox/config_flow.py @@ -4,7 +4,6 @@ import logging from typing import Any -from httpx import AsyncClient from pythonxbox.api.client import XboxLiveClient from pythonxbox.authentication.manager import AuthenticationManager from pythonxbox.authentication.models import OAuth2TokenResponse @@ -20,6 +19,7 @@ ) from homeassistant.core import callback from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, @@ -67,14 +67,14 @@ async def async_step_user( async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: """Create an entry for the flow.""" - async with AsyncClient() as session: - auth = AuthenticationManager(session, "", "", "") - auth.oauth = OAuth2TokenResponse(**data["token"]) - await auth.refresh_tokens() + session = get_async_client(self.hass) + auth = AuthenticationManager(session, "", "", "") + auth.oauth = OAuth2TokenResponse(**data["token"]) + await auth.refresh_tokens() - client = XboxLiveClient(auth) + client = XboxLiveClient(auth) - me = await client.people.get_friends_by_xuid(client.xuid) + me = await client.people.get_friend_by_xuid(client.xuid) await self.async_set_unique_id(client.xuid) diff --git a/homeassistant/components/xbox/coordinator.py b/homeassistant/components/xbox/coordinator.py index 6232fc2272d458..fa0c3eec595cca 100644 --- a/homeassistant/components/xbox/coordinator.py +++ b/homeassistant/components/xbox/coordinator.py @@ -213,10 +213,10 @@ class XboxPresenceCoordinator(XboxBaseCoordinator[XboxData]): async def update_data(self) -> XboxData: """Fetch presence data.""" - batch = await self.client.people.get_friends_by_xuid(self.client.xuid) + me = await self.client.people.get_friend_by_xuid(self.client.xuid) friends = await self.client.people.get_friends_own() - presence_data = {self.client.xuid: batch.people[0]} + presence_data = {self.client.xuid: me.people[0]} presence_data.update( { friend.xuid: friend diff --git a/homeassistant/components/xbox/manifest.json b/homeassistant/components/xbox/manifest.json index 0417040012a44e..7be5e252ea59fd 100644 --- a/homeassistant/components/xbox/manifest.json +++ b/homeassistant/components/xbox/manifest.json @@ -12,7 +12,9 @@ "documentation": "https://www.home-assistant.io/integrations/xbox", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["python-xbox==0.1.3"], + "quality_scale": "platinum", + + "requirements": ["python-xbox==0.2.0"], "ssdp": [ { "manufacturer": "Microsoft Corporation", diff --git a/homeassistant/components/xbox/quality_scale.yaml b/homeassistant/components/xbox/quality_scale.yaml new file mode 100644 index 00000000000000..617ecc0a15daf6 --- /dev/null +++ b/homeassistant/components/xbox/quality_scale.yaml @@ -0,0 +1,74 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: has only entity 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: has only entity actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: The integration has no configuration options + docs-installation-parameters: + status: exempt + comment: The integration has no installation parameters + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: Discovery is only used to start/suggest the OAuth flow; there is no connection info to update + 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: + status: exempt + comment: nothing to reconfigure + repair-issues: + status: exempt + comment: has no repairs + stale-devices: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/xbox/sensor.py b/homeassistant/components/xbox/sensor.py index 92907c92c64743..e192f11c3bdbe7 100644 --- a/homeassistant/components/xbox/sensor.py +++ b/homeassistant/components/xbox/sensor.py @@ -44,6 +44,16 @@ "followed": "joinable", } +MAP_PLATFORM_NAME = { + "Android": "Android", + "iOS": "iOS", + "Nintendo": "Nintendo Switch", + "Scarlett": "Xbox Series X|S", + "WindowsOneCore": "Windows", + "Xbox360": "Xbox 360", + "XboxOne": "Xbox One", +} + class XboxSensor(StrEnum): """Xbox sensor.""" @@ -63,6 +73,9 @@ class XboxSensor(StrEnum): FREE_STORAGE = "free_storage" +PRESENCE_ACTIVE = "Active" + + @dataclass(kw_only=True, frozen=True) class XboxSensorEntityDescription(XboxBaseEntityDescription, SensorEntityDescription): """Xbox sensor description.""" @@ -79,7 +92,7 @@ class XboxStorageDeviceSensorEntityDescription( value_fn: Callable[[StorageDevice], StateType] -def now_playing_attributes(_: Person, title: Title | None) -> dict[str, Any]: +def now_playing_attributes(person: Person, title: Title | None) -> dict[str, Any]: """Attributes of the currently played title.""" attributes: dict[str, Any] = { "short_description": None, @@ -91,9 +104,35 @@ def now_playing_attributes(_: Person, title: Title | None) -> dict[str, Any]: "achievements": None, "gamerscore": None, "progress": None, + "platform": None, } + + if person.presence_details: + active_entry = next( + ( + d + for d in person.presence_details + if d.state == PRESENCE_ACTIVE and d.is_game + ), + None, + ) or next( + (d for d in person.presence_details if d.state == PRESENCE_ACTIVE), + None, + ) + + if active_entry: + platform = active_entry.device + if platform == "Scarlett" and title and title.devices: + if "Xbox360" in title.devices: + platform = "Xbox360" + elif "XboxOne" in title.devices: + platform = "XboxOne" + + attributes["platform"] = MAP_PLATFORM_NAME.get(platform, platform) + if not title: return attributes + if title.detail is not None: attributes.update( { @@ -160,6 +199,7 @@ def title_logo(_: Person, title: Title | None) -> str | None: key=XboxSensor.GAMER_SCORE, translation_key=XboxSensor.GAMER_SCORE, value_fn=lambda x, _: x.gamer_score, + state_class=SensorStateClass.MEASUREMENT, ), XboxSensorEntityDescription( key=XboxSensor.ACCOUNT_TIER, @@ -187,11 +227,13 @@ def title_logo(_: Person, title: Title | None) -> str | None: key=XboxSensor.FOLLOWING, translation_key=XboxSensor.FOLLOWING, value_fn=lambda x, _: x.detail.following_count if x.detail else None, + state_class=SensorStateClass.MEASUREMENT, ), XboxSensorEntityDescription( key=XboxSensor.FOLLOWER, translation_key=XboxSensor.FOLLOWER, value_fn=lambda x, _: x.detail.follower_count if x.detail else None, + state_class=SensorStateClass.MEASUREMENT, ), XboxSensorEntityDescription( key=XboxSensor.NOW_PLAYING, @@ -204,6 +246,7 @@ def title_logo(_: Person, title: Title | None) -> str | None: key=XboxSensor.FRIENDS, translation_key=XboxSensor.FRIENDS, value_fn=lambda x, _: x.detail.friend_count if x.detail else None, + state_class=SensorStateClass.MEASUREMENT, ), XboxSensorEntityDescription( key=XboxSensor.IN_PARTY, diff --git a/homeassistant/components/xbox/strings.json b/homeassistant/components/xbox/strings.json index db783ed439861c..c7a651353b38b6 100644 --- a/homeassistant/components/xbox/strings.json +++ b/homeassistant/components/xbox/strings.json @@ -141,6 +141,7 @@ }, "genres": { "name": "Genres" }, "min_age": { "name": "Minimum age" }, + "platform": { "name": "Platform" }, "progress": { "name": "Progress" }, "publisher": { "name": "Publisher" }, "release_date": { "name": "Release date" }, diff --git a/homeassistant/components/xiaomi_aqara/binary_sensor.py b/homeassistant/components/xiaomi_aqara/binary_sensor.py index b7a6d7ba93501c..544cd6f7e318d3 100644 --- a/homeassistant/components/xiaomi_aqara/binary_sensor.py +++ b/homeassistant/components/xiaomi_aqara/binary_sensor.py @@ -181,11 +181,12 @@ def __init__( ) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - attrs = {ATTR_DENSITY: self._density} - attrs.update(super().extra_state_attributes) - return attrs + return { + ATTR_DENSITY: self._density, + **self._attr_extra_state_attributes, + } async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" @@ -243,11 +244,12 @@ def __init__( ) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - attrs = {ATTR_NO_MOTION_SINCE: self._no_motion_since} - attrs.update(super().extra_state_attributes) - return attrs + return { + ATTR_NO_MOTION_SINCE: self._no_motion_since, + **self._attr_extra_state_attributes, + } @callback def _async_set_no_motion(self, now): @@ -349,11 +351,12 @@ def __init__( ) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - attrs = {ATTR_OPEN_SINCE: self._open_since} - attrs.update(super().extra_state_attributes) - return attrs + return { + ATTR_OPEN_SINCE: self._open_since, + **self._attr_extra_state_attributes, + } async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" @@ -462,11 +465,12 @@ def __init__( ) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - attrs = {ATTR_DENSITY: self._density} - attrs.update(super().extra_state_attributes) - return attrs + return { + ATTR_DENSITY: self._density, + **self._attr_extra_state_attributes, + } async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" @@ -511,11 +515,12 @@ def __init__( super().__init__(device, name, xiaomi_hub, data_key, None, config_entry) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - attrs = {ATTR_LAST_ACTION: self._last_action} - attrs.update(super().extra_state_attributes) - return attrs + return { + ATTR_LAST_ACTION: self._last_action, + **self._attr_extra_state_attributes, + } async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" @@ -559,11 +564,12 @@ def __init__( super().__init__(device, name, xiaomi_hub, data_key, None, config_entry) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - attrs = {ATTR_LAST_ACTION: self._last_action} - attrs.update(super().extra_state_attributes) - return attrs + return { + ATTR_LAST_ACTION: self._last_action, + **self._attr_extra_state_attributes, + } async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" @@ -629,11 +635,12 @@ def __init__( super().__init__(device, "Cube", xiaomi_hub, data_key, None, config_entry) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - attrs = {ATTR_LAST_ACTION: self._last_action} - attrs.update(super().extra_state_attributes) - return attrs + return { + ATTR_LAST_ACTION: self._last_action, + **self._attr_extra_state_attributes, + } async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" diff --git a/homeassistant/components/xiaomi_aqara/light.py b/homeassistant/components/xiaomi_aqara/light.py index 47b9e5a673058c..585ab39ba6bd1d 100644 --- a/homeassistant/components/xiaomi_aqara/light.py +++ b/homeassistant/components/xiaomi_aqara/light.py @@ -91,12 +91,12 @@ def parse_data(self, data, raw_data): return True @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" return int(255 * self._brightness / 100) @property - def hs_color(self): + def hs_color(self) -> tuple[float, float]: """Return the hs color value.""" return self._hs diff --git a/homeassistant/components/xiaomi_aqara/manifest.json b/homeassistant/components/xiaomi_aqara/manifest.json index 75d4b0b9a00921..1142f25baf4381 100644 --- a/homeassistant/components/xiaomi_aqara/manifest.json +++ b/homeassistant/components/xiaomi_aqara/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@danielhiversen", "@syssi"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/xiaomi_aqara", + "integration_type": "hub", "iot_class": "local_push", "loggers": ["xiaomi_gateway"], "requirements": ["PyXiaomiGateway==0.14.3"], diff --git a/homeassistant/components/xiaomi_aqara/switch.py b/homeassistant/components/xiaomi_aqara/switch.py index e9e2c92314e3d0..69cba6491cdb71 100644 --- a/homeassistant/components/xiaomi_aqara/switch.py +++ b/homeassistant/components/xiaomi_aqara/switch.py @@ -158,25 +158,23 @@ def __init__( super().__init__(device, name, xiaomi_hub, config_entry) @property - def icon(self): + def icon(self) -> str: """Return the icon to use in the frontend, if any.""" if self._data_key == "status": return "mdi:power-plug" return "mdi:power-socket" @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" if self._supports_power_consumption: - attrs = { + return { ATTR_IN_USE: self._in_use, ATTR_LOAD_POWER: self._load_power, ATTR_POWER_CONSUMED: self._power_consumed, + **self._attr_extra_state_attributes, } - else: - attrs = {} - attrs.update(super().extra_state_attributes) - return attrs + return self._attr_extra_state_attributes def turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 05e2fbe6043521..76eb64677807d2 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -5,7 +5,6 @@ import asyncio from collections.abc import Callable, Coroutine from dataclasses import dataclass -from datetime import timedelta import logging from typing import Any @@ -33,7 +32,6 @@ Timer, VacuumStatus, ) -from miio.gateway.gateway import GatewayException from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_TOKEN, Platform from homeassistant.core import HomeAssistant, callback @@ -47,7 +45,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( - ATTR_AVAILABLE, CONF_FLOW_TYPE, CONF_GATEWAY, DOMAIN, @@ -76,6 +73,7 @@ AuthException, SetupException, ) +from .coordinator import UPDATE_INTERVAL, GatewayDeviceCoordinator from .gateway import ConnectXiaomiGateway from .services import async_setup_services from .typing import XiaomiMiioConfigEntry, XiaomiMiioRuntimeData @@ -84,7 +82,6 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) POLLING_TIMEOUT_SEC = 10 -UPDATE_INTERVAL = timedelta(seconds=15) GATEWAY_PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, @@ -446,31 +443,11 @@ async def async_setup_gateway_entry( hw_version=gateway_info.hardware_version, ) - def update_data_factory(sub_device): - """Create update function for a subdevice.""" - - async def async_update_data(): - """Fetch data from the subdevice.""" - try: - await hass.async_add_executor_job(sub_device.update) - except GatewayException as ex: - _LOGGER.error("Got exception while fetching the state: %s", ex) - return {ATTR_AVAILABLE: False} - return {ATTR_AVAILABLE: True} - - return async_update_data - - coordinator_dict: dict[str, DataUpdateCoordinator] = {} + coordinator_dict: dict[str, GatewayDeviceCoordinator] = {} for sub_device in gateway.gateway_device.devices.values(): # Create update coordinator - coordinator_dict[sub_device.sid] = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name=name, - update_method=update_data_factory(sub_device), - # Polling interval. Will only be polled if there are subscribers. - update_interval=UPDATE_INTERVAL, + coordinator_dict[sub_device.sid] = GatewayDeviceCoordinator( + hass, entry, sub_device ) entry.runtime_data = XiaomiMiioRuntimeData( diff --git a/homeassistant/components/xiaomi_miio/air_quality.py b/homeassistant/components/xiaomi_miio/air_quality.py index 9e52abb1c85442..95f29f6697c137 100644 --- a/homeassistant/components/xiaomi_miio/air_quality.py +++ b/homeassistant/components/xiaomi_miio/air_quality.py @@ -2,6 +2,7 @@ from collections.abc import Callable import logging +from typing import Any from miio import ( AirQualityMonitor, @@ -116,7 +117,7 @@ def humidity(self): return self._humidity @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" data = {} diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index 94fbf427367afa..67a2d1b77c14e4 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -27,10 +27,6 @@ # Options flow CONF_CLOUD_SUBDEVICES = "cloud_subdevices" - -# Attributes -ATTR_AVAILABLE = "available" - # Status SUCCESS = ["ok"] diff --git a/homeassistant/components/xiaomi_miio/coordinator.py b/homeassistant/components/xiaomi_miio/coordinator.py new file mode 100644 index 00000000000000..32c10199c53219 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/coordinator.py @@ -0,0 +1,51 @@ +"""Support for Xiaomi Miio.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from miio.gateway.devices import SubDevice +from miio.gateway.gateway import GatewayException + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .typing import XiaomiMiioConfigEntry + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL = timedelta(seconds=15) + + +class GatewayDeviceCoordinator(DataUpdateCoordinator[None]): + """Coordinator for Xiaomi Gateway subdevices.""" + + config_entry: XiaomiMiioConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: XiaomiMiioConfigEntry, + sub_device: SubDevice, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"Xiaomi Gateway subdevice {sub_device.sid}", + config_entry=entry, + update_interval=UPDATE_INTERVAL, + ) + self.sub_device = sub_device + # Mark as unavailable until the first update is successful + self.last_update_success = False + + async def _async_update_data(self) -> None: + """Fetch data from the subdevice.""" + try: + await self.hass.async_add_executor_job(self.sub_device.update) + except GatewayException as ex: + raise UpdateFailed( + f"Error fetching data from subdevice {self.sub_device.sid}: {ex}" + ) from ex diff --git a/homeassistant/components/xiaomi_miio/entity.py b/homeassistant/components/xiaomi_miio/entity.py index f5da22265c4586..b776061f92123e 100644 --- a/homeassistant/components/xiaomi_miio/entity.py +++ b/homeassistant/components/xiaomi_miio/entity.py @@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, Any from miio import Device as MiioDevice, DeviceException -from miio.gateway.devices import SubDevice from homeassistant.const import ATTR_CONNECTIONS, CONF_MAC, CONF_MODEL from homeassistant.helpers import device_registry as dr @@ -18,7 +17,8 @@ DataUpdateCoordinator, ) -from .const import ATTR_AVAILABLE, DOMAIN +from .const import DOMAIN +from .coordinator import GatewayDeviceCoordinator from .typing import XiaomiMiioConfigEntry _LOGGER = logging.getLogger(__name__) @@ -153,23 +153,18 @@ def _parse_datetime_datetime(time: datetime.datetime) -> str: return time.isoformat() -class XiaomiGatewayDevice( - CoordinatorEntity[DataUpdateCoordinator[dict[str, bool]]], Entity -): +class XiaomiGatewayDevice(CoordinatorEntity[GatewayDeviceCoordinator], Entity): """Representation of a base Xiaomi Gateway Device.""" - def __init__( - self, - coordinator: DataUpdateCoordinator[dict[str, bool]], - sub_device: SubDevice, - entry: XiaomiMiioConfigEntry, - ) -> None: + def __init__(self, coordinator: GatewayDeviceCoordinator) -> None: """Initialize the Xiaomi Gateway Device.""" super().__init__(coordinator) - self._sub_device = sub_device - self._entry = entry - self._attr_unique_id = sub_device.sid - self._attr_name = f"{sub_device.name} ({sub_device.sid})" + self._sub_device = coordinator.sub_device + self._entry = coordinator.config_entry + self._attr_unique_id = coordinator.sub_device.sid + self._attr_name = ( + f"{coordinator.sub_device.name} ({coordinator.sub_device.sid})" + ) @property def device_info(self) -> DeviceInfo: @@ -185,11 +180,3 @@ def device_info(self) -> DeviceInfo: sw_version=self._sub_device.firmware_version, hw_version=self._sub_device.zigbee_model, ) - - @property - def available(self) -> bool: - """Return if entity is available.""" - if self.coordinator.data is None: - return False - - return self.coordinator.data[ATTR_AVAILABLE] diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index 0ff6df93d3e736..ab11572006e632 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -140,6 +140,7 @@ async def async_setup_entry( if config_entry.data[CONF_FLOW_TYPE] == CONF_GATEWAY: gateway = config_entry.runtime_data.gateway + gateway_coordinators = config_entry.runtime_data.gateway_coordinators # Gateway light if gateway.model not in [ GATEWAY_MODEL_AC_V1, @@ -151,14 +152,11 @@ async def async_setup_entry( ) # Gateway sub devices sub_devices = gateway.devices - for sub_device in sub_devices.values(): - if sub_device.device_type == "LightBulb": - coordinator = config_entry.runtime_data.gateway_coordinators[ - sub_device.sid - ] - entities.append( - XiaomiGatewayBulb(coordinator, sub_device, config_entry) - ) + entities.extend( + XiaomiGatewayBulb(gateway_coordinators[sub_device.sid]) + for sub_device in sub_devices.values() + if sub_device.device_type == "LightBulb" + ) if config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: if DATA_KEY not in hass.data: @@ -1041,12 +1039,12 @@ def device_info(self) -> DeviceInfo: ) @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" return int(255 * self._brightness_pct / 100) @property - def hs_color(self): + def hs_color(self) -> tuple[float, float]: """Return the hs color value.""" return self._hs @@ -1102,7 +1100,7 @@ class XiaomiGatewayBulb(XiaomiGatewayDevice, LightEntity): _sub_device: LightBulb @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of the light.""" return round((self._sub_device.status["brightness"] * 255) / 100) diff --git a/homeassistant/components/xiaomi_miio/remote.py b/homeassistant/components/xiaomi_miio/remote.py index b5c7fa8710a06d..03b778ee358852 100644 --- a/homeassistant/components/xiaomi_miio/remote.py +++ b/homeassistant/components/xiaomi_miio/remote.py @@ -211,7 +211,7 @@ def timeout(self): return self._timeout @property - def is_on(self): + def is_on(self) -> bool: """Return False if device is unreachable, else True.""" try: self.device.info() diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index eb630e6d28fe0f..70deeb141c0fed 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -8,7 +8,6 @@ from typing import TYPE_CHECKING, Any from miio import AirQualityMonitor, Device as MiioDevice, DeviceException -from miio.gateway.devices import SubDevice from miio.gateway.gateway import ( GATEWAY_MODEL_AC_V1, GATEWAY_MODEL_AC_V2, @@ -90,6 +89,7 @@ ROBOROCK_GENERIC, ROCKROBO_GENERIC, ) +from .coordinator import GatewayDeviceCoordinator from .entity import XiaomiCoordinatedMiioEntity, XiaomiGatewayDevice, XiaomiMiioEntity from .typing import XiaomiMiioConfigEntry @@ -769,6 +769,7 @@ async def async_setup_entry( if config_entry.data[CONF_FLOW_TYPE] == CONF_GATEWAY: gateway = config_entry.runtime_data.gateway + gateway_coordinators = config_entry.runtime_data.gateway_coordinators # Gateway illuminance sensor if gateway.model not in [ GATEWAY_MODEL_AC_V1, @@ -786,13 +787,12 @@ async def async_setup_entry( # Gateway sub devices sub_devices = gateway.devices for sub_device in sub_devices.values(): - coordinator = config_entry.runtime_data.gateway_coordinators[sub_device.sid] for sensor, description in SENSOR_TYPES.items(): if sensor not in sub_device.status: continue entities.append( XiaomiGatewaySensor( - coordinator, sub_device, config_entry, description + gateway_coordinators[sub_device.sid], description ) ) elif config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: @@ -982,15 +982,13 @@ class XiaomiGatewaySensor(XiaomiGatewayDevice, SensorEntity): def __init__( self, - coordinator: DataUpdateCoordinator[dict[str, bool]], - sub_device: SubDevice, - entry: XiaomiMiioConfigEntry, + coordinator: GatewayDeviceCoordinator, description: XiaomiMiioSensorDescription, ) -> None: - """Initialize the XiaomiSensor.""" - super().__init__(coordinator, sub_device, entry) - self._attr_unique_id = f"{sub_device.sid}-{description.key}" - self._attr_name = f"{description.key} ({sub_device.sid})".capitalize() + """Initialize the XiaomiGatewaySensor.""" + super().__init__(coordinator) + self._attr_unique_id = f"{self._sub_device.sid}-{description.key}" + self._attr_name = f"{description.key} ({self._sub_device.sid})".capitalize() self.entity_description = description @property diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index bc922671a9f074..a5375433ed83bf 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -15,7 +15,6 @@ DeviceException, PowerStrip, ) -from miio.gateway.devices import SubDevice from miio.gateway.devices.switch import Switch from miio.powerstrip import PowerMode import voluptuous as vol @@ -121,6 +120,7 @@ SERVICE_SET_WIFI_LED_ON, SUCCESS, ) +from .coordinator import GatewayDeviceCoordinator from .entity import XiaomiCoordinatedMiioEntity, XiaomiGatewayDevice, XiaomiMiioEntity from .typing import ServiceMethodDetails, XiaomiMiioConfigEntry @@ -411,18 +411,18 @@ async def async_setup_other_entry( unique_id = config_entry.unique_id if config_entry.data[CONF_FLOW_TYPE] == CONF_GATEWAY: gateway = config_entry.runtime_data.gateway + gateway_coordinators = config_entry.runtime_data.gateway_coordinators # Gateway sub devices sub_devices = gateway.devices for sub_device in sub_devices.values(): if sub_device.device_type != "Switch": continue - coordinator = config_entry.runtime_data.gateway_coordinators[sub_device.sid] switch_variables = set(sub_device.status) & set(GATEWAY_SWITCH_VARS) if switch_variables: entities.extend( [ XiaomiGatewaySwitch( - coordinator, sub_device, config_entry, variable + gateway_coordinators[sub_device.sid], variable ) for variable in switch_variables ] @@ -768,19 +768,15 @@ class XiaomiGatewaySwitch(XiaomiGatewayDevice, SwitchEntity): _attr_device_class = SwitchDeviceClass.SWITCH _sub_device: Switch - def __init__( - self, - coordinator: DataUpdateCoordinator[dict[str, bool]], - sub_device: SubDevice, - entry: XiaomiMiioConfigEntry, - variable: str, - ) -> None: - """Initialize the XiaomiSensor.""" - super().__init__(coordinator, sub_device, entry) + def __init__(self, coordinator: GatewayDeviceCoordinator, variable: str) -> None: + """Initialize the XiaomiGatewaySwitch.""" + super().__init__(coordinator) self._channel = GATEWAY_SWITCH_VARS[variable][KEY_CHANNEL] self._data_key = f"status_ch{self._channel}" - self._attr_unique_id = f"{sub_device.sid}-ch{self._channel}" - self._attr_name = f"{sub_device.name} ch{self._channel} ({sub_device.sid})" + self._attr_unique_id = f"{self._sub_device.sid}-ch{self._channel}" + self._attr_name = ( + f"{self._sub_device.name} ch{self._channel} ({self._sub_device.sid})" + ) @property def is_on(self) -> bool: diff --git a/homeassistant/components/xiaomi_miio/typing.py b/homeassistant/components/xiaomi_miio/typing.py index e657f58fbce860..79c69b1b565142 100644 --- a/homeassistant/components/xiaomi_miio/typing.py +++ b/homeassistant/components/xiaomi_miio/typing.py @@ -1,7 +1,7 @@ """Typings for the xiaomi_miio integration.""" from dataclasses import dataclass -from typing import Any, NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple from miio import Device as MiioDevice from miio.gateway.gateway import Gateway @@ -10,6 +10,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +if TYPE_CHECKING: + from .coordinator import GatewayDeviceCoordinator + class ServiceMethodDetails(NamedTuple): """Details for SERVICE_TO_METHOD mapping.""" @@ -30,7 +33,7 @@ class XiaomiMiioRuntimeData: device_coordinator: DataUpdateCoordinator[Any] = None # type: ignore[assignment] gateway: Gateway = None # type: ignore[assignment] - gateway_coordinators: dict[str, DataUpdateCoordinator[dict[str, bool]]] = None # type: ignore[assignment] + gateway_coordinators: dict[str, GatewayDeviceCoordinator] = None # type: ignore[assignment] type XiaomiMiioConfigEntry = ConfigEntry[XiaomiMiioRuntimeData] diff --git a/homeassistant/components/yale/__init__.py b/homeassistant/components/yale/__init__.py index b018f4a2287cf2..07d348bc00670e 100644 --- a/homeassistant/components/yale/__init__.py +++ b/homeassistant/components/yale/__init__.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import cast -from aiohttp import ClientResponseError +from aiohttp import ClientError from yalexs.const import Brand from yalexs.exceptions import YaleApiError from yalexs.manager.const import CONF_BRAND @@ -15,7 +15,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import 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 device_registry as dr from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, @@ -42,11 +47,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bool yale_gateway = YaleGateway(Path(hass.config.config_dir), session, oauth_session) try: await async_setup_yale(hass, entry, yale_gateway) + except OAuth2TokenRequestReauthError as err: + raise ConfigEntryAuthFailed from err except (RequireValidation, InvalidAuth) as err: raise ConfigEntryAuthFailed from err except TimeoutError as err: raise ConfigEntryNotReady("Timed out connecting to yale api") from err - except (YaleApiError, ClientResponseError, CannotConnect) as err: + except ( + YaleApiError, + OAuth2TokenRequestError, + ClientError, + CannotConnect, + ) as err: raise ConfigEntryNotReady from err await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json index 8acd61add7c362..d8eea99f1e9795 100644 --- a/homeassistant/components/yale/manifest.json +++ b/homeassistant/components/yale/manifest.json @@ -11,7 +11,8 @@ } ], "documentation": "https://www.home-assistant.io/integrations/yale", + "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["socketio", "engineio", "yalexs"], - "requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.4"] + "requirements": ["yalexs==9.2.0", "yalexs-ble==3.3.0"] } diff --git a/homeassistant/components/yale_smart_alarm/manifest.json b/homeassistant/components/yale_smart_alarm/manifest.json index 9a13cf72db9cf1..e9694a77314fb8 100644 --- a/homeassistant/components/yale_smart_alarm/manifest.json +++ b/homeassistant/components/yale_smart_alarm/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@gjohansson-ST"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/yale_smart_alarm", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["yalesmartalarmclient"], "requirements": ["yalesmartalarmclient==0.4.3"] diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 1c0fdaa0f061e7..cf60aa86253571 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -11,6 +11,7 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", + "integration_type": "device", "iot_class": "local_push", - "requirements": ["yalexs-ble==3.2.4"] + "requirements": ["yalexs-ble==3.3.0"] } diff --git a/homeassistant/components/yamaha_musiccast/manifest.json b/homeassistant/components/yamaha_musiccast/manifest.json index 6320f549908cfe..92889ca495a955 100644 --- a/homeassistant/components/yamaha_musiccast/manifest.json +++ b/homeassistant/components/yamaha_musiccast/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["ssdp"], "documentation": "https://www.home-assistant.io/integrations/yamaha_musiccast", + "integration_type": "device", "iot_class": "local_push", "loggers": ["aiomusiccast"], "requirements": ["aiomusiccast==0.15.0"], diff --git a/homeassistant/components/yardian/manifest.json b/homeassistant/components/yardian/manifest.json index ba6396e1f75979..6023657277e98b 100644 --- a/homeassistant/components/yardian/manifest.json +++ b/homeassistant/components/yardian/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@h3l1o5"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/yardian", + "integration_type": "device", "iot_class": "local_polling", "requirements": ["pyyardian==1.1.1"] } diff --git a/homeassistant/components/yeelight/binary_sensor.py b/homeassistant/components/yeelight/binary_sensor.py index 69427c65fd5fde..9d9657892f0484 100644 --- a/homeassistant/components/yeelight/binary_sensor.py +++ b/homeassistant/components/yeelight/binary_sensor.py @@ -48,6 +48,6 @@ def unique_id(self) -> str: return f"{self._unique_id}-nightlight_sensor" @property - def is_on(self): + def is_on(self) -> bool: """Return true if nightlight mode is on.""" return self._device.is_nightlight_enabled diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 20d434da3c21b0..26c776975cd8e6 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -14,6 +14,7 @@ "homekit": { "models": ["YL*"] }, + "integration_type": "device", "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], "requirements": ["yeelight==0.7.16", "async-upnp-client==0.46.2"], diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index cf6dc645c4bf4f..4b095a0439c1ee 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/yolink", + "integration_type": "hub", "iot_class": "cloud_push", "requirements": ["yolink-api==0.6.1"] } diff --git a/homeassistant/components/youless/manifest.json b/homeassistant/components/youless/manifest.json index 9a51e0fe0d174f..a493f51bc13a37 100644 --- a/homeassistant/components/youless/manifest.json +++ b/homeassistant/components/youless/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@gjong"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/youless", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["youless_api"], "requirements": ["youless-api==2.2.0"] diff --git a/homeassistant/components/zamg/manifest.json b/homeassistant/components/zamg/manifest.json index f59231f2728437..c5f3784ddd8aa9 100644 --- a/homeassistant/components/zamg/manifest.json +++ b/homeassistant/components/zamg/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@killer0071234"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zamg", + "integration_type": "service", "iot_class": "cloud_polling", "requirements": ["zamg==0.3.6"] } diff --git a/homeassistant/components/zeroconf/repairs.py b/homeassistant/components/zeroconf/repairs.py index 3afde331a42fb3..2af53ff46257bb 100644 --- a/homeassistant/components/zeroconf/repairs.py +++ b/homeassistant/components/zeroconf/repairs.py @@ -4,7 +4,7 @@ from homeassistant import data_entry_flow from homeassistant.components.homeassistant import ( - DOMAIN as DOMAIN_HOMEASSISTANT, + DOMAIN as HOMEASSISTANT_DOMAIN, SERVICE_HOMEASSISTANT_RESTART, ) from homeassistant.components.repairs import RepairsFlow @@ -35,7 +35,7 @@ async def async_step_confirm_recreate( if user_input is not None: await instance_id.async_recreate(self.hass) await self.hass.services.async_call( - DOMAIN_HOMEASSISTANT, SERVICE_HOMEASSISTANT_RESTART + HOMEASSISTANT_DOMAIN, SERVICE_HOMEASSISTANT_RESTART ) return self.async_create_entry(title="", data={}) diff --git a/homeassistant/components/zerproc/manifest.json b/homeassistant/components/zerproc/manifest.json index a40a1b00b80456..0abc45d64f51a0 100644 --- a/homeassistant/components/zerproc/manifest.json +++ b/homeassistant/components/zerproc/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@emlove"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zerproc", + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["bleak", "pyzerproc"], "requirements": ["pyzerproc==0.4.8"] diff --git a/homeassistant/components/zestimate/sensor.py b/homeassistant/components/zestimate/sensor.py index 6b3b38bdde859c..c776cce2ca0f85 100644 --- a/homeassistant/components/zestimate/sensor.py +++ b/homeassistant/components/zestimate/sensor.py @@ -4,6 +4,7 @@ from datetime import timedelta import logging +from typing import Any import requests import voluptuous as vol @@ -99,7 +100,7 @@ def native_value(self): return None @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" attributes = {} if self.data is not None: diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 16c64fd90169aa..335a0939b05137 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -274,6 +274,9 @@ def update_config(event: Event) -> None: async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload ZHA config entry.""" + if not await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS): + return False + ha_zha_data = get_zha_data(hass) ha_zha_data.config_entry = None @@ -281,6 +284,8 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> await ha_zha_data.gateway_proxy.shutdown() ha_zha_data.gateway_proxy = None + ha_zha_data.update_coordinator = None + # clean up any remaining entity metadata # (entities that have been discovered but not yet added to HA) # suppress KeyError because we don't know what state we may @@ -291,7 +296,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> websocket_api.async_unload_api(hass) - return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + return True async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index d20b0e5bdb8b12..54034fc6b13409 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -103,6 +103,12 @@ 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.""" @@ -176,7 +182,12 @@ async def list_serial_ports(hass: HomeAssistant) -> list[USBDevice]: ports.append(addon_port) - return ports + # Filter out ignored USB devices + return [ + port + for port in ports + if (port.manufacturer, port.description) not in IGNORED_USB_DEVICES + ] class BaseZhaFlow(ConfigEntryBaseFlow): diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index 36b9a001506a19..213d5d11150caa 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -67,8 +67,12 @@ def __init__(self, entity_data: EntityData) -> None: self.entity_data.entity.info_object.device_class ) + @staticmethod + def _convert_supported_features( + zha_features: ZHACoverEntityFeature, + ) -> CoverEntityFeature: + """Convert ZHA cover features to HA cover features.""" features = CoverEntityFeature(0) - zha_features: ZHACoverEntityFeature = self.entity_data.entity.supported_features if ZHACoverEntityFeature.OPEN in zha_features: features |= CoverEntityFeature.OPEN @@ -87,7 +91,13 @@ def __init__(self, entity_data: EntityData) -> None: if ZHACoverEntityFeature.SET_TILT_POSITION in zha_features: features |= CoverEntityFeature.SET_TILT_POSITION - self._attr_supported_features = features + return features + + @property + def supported_features(self) -> CoverEntityFeature: + """Return the supported features.""" + zha_features: ZHACoverEntityFeature = self.entity_data.entity.supported_features + return self._convert_supported_features(zha_features) @property def is_closed(self) -> bool | None: diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index de09f420730ae7..f3a0d0584c2bec 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -22,6 +22,7 @@ from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity +from homeassistant.helpers.group import IntegrationSpecificGroup from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import UNDEFINED, UndefinedType @@ -51,6 +52,18 @@ def __init__(self, entity_data: EntityData, *args, **kwargs) -> None: meta = self.entity_data.entity.info_object self._attr_unique_id = meta.unique_id + if self.entity_data.is_group_entity: + group_proxy = self.entity_data.group_proxy + assert group_proxy is not None + platform = self.entity_data.entity.PLATFORM + unique_ids = [ + entity.info_object.unique_id + for member in group_proxy.group.members + for entity in member.associated_entities + if platform == entity.PLATFORM + ] + self.group = IntegrationSpecificGroup(self, unique_ids) + if meta.entity_category is not None: self._attr_entity_category = EntityCategory(meta.entity_category) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 47811a9f82a5dd..0e67aab7f10b39 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -23,7 +23,7 @@ "universal_silabs_flasher", "serialx" ], - "requirements": ["zha==0.0.90", "serialx==0.6.2"], + "requirements": ["zha==1.0.2", "serialx==0.6.2"], "usb": [ { "description": "*2652*", diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 7a6e40af7e7fcb..4df9c7611bcc59 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -4,8 +4,9 @@ import functools import logging +from typing import Any -from homeassistant.components.number import RestoreNumber +from homeassistant.components.number import NumberDeviceClass, NumberMode, RestoreNumber from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -15,6 +16,7 @@ from .entity import ZHAEntity from .helpers import ( SIGNAL_ADD_ENTITIES, + EntityData, async_add_entities as zha_async_add_entities, convert_zha_error_to_ha_error, get_zha_data, @@ -45,6 +47,14 @@ async def async_setup_entry( class ZhaNumber(ZHAEntity, RestoreNumber): """Representation of a ZHA Number entity.""" + def __init__(self, entity_data: EntityData, **kwargs: Any) -> None: + """Initialize the ZHA number entity.""" + super().__init__(entity_data, **kwargs) + entity = entity_data.entity + if entity.device_class is not None: + self._attr_device_class = NumberDeviceClass(entity.device_class) + self._attr_mode = NumberMode(entity.mode) + @property def native_value(self) -> float | None: """Return the current value.""" diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 4b1b629f8af353..12391d01bb67eb 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -335,18 +335,39 @@ } }, "button": { + "boost_mode": { + "name": "Boost mode" + }, "calibrate_valve": { "name": "Calibrate valve" }, "calibrate_z_axis": { "name": "Calibrate Z axis" }, + "delete_all_limits": { + "name": "Delete all limits" + }, + "delete_lower_limit": { + "name": "Delete lower limit" + }, + "delete_upper_limit": { + "name": "Delete upper limit" + }, + "enter_calibration_mode": { + "name": "Enter calibration mode" + }, + "exit_calibration_mode": { + "name": "Exit calibration mode" + }, "feed": { "name": "Feed" }, "frost_lock_reset": { "name": "Frost lock reset" }, + "prepare_manual_calibration": { + "name": "Prepare manual calibration" + }, "reset_alarm": { "name": "Reset alarm" }, @@ -368,8 +389,20 @@ "restart_device": { "name": "Restart device" }, + "run_auto_calibration": { + "name": "Run auto-calibration" + }, "self_test": { "name": "Self-test" + }, + "set_lower_limit": { + "name": "Set lower limit" + }, + "set_upper_limit": { + "name": "Set upper limit" + }, + "timer_mode": { + "name": "Timer mode" } }, "climate": { @@ -425,6 +458,9 @@ } }, "number": { + "additional_steps": { + "name": "Additional steps" + }, "alarm_duration": { "name": "Alarm duration" }, @@ -488,6 +524,12 @@ "calibration_vertical_run_time_up": { "name": "Calibration vertical run time up" }, + "closed_limit_lift": { + "name": "Closed limit lift" + }, + "closed_limit_tilt": { + "name": "Closed limit tilt" + }, "closing_duration": { "name": "Closing duration" }, @@ -629,6 +671,9 @@ "impulse_mode_duration": { "name": "Impulse mode duration" }, + "inactive_power_threshold": { + "name": "Inactive power threshold" + }, "installation_height": { "name": "Height from sensor to tank bottom" }, @@ -701,6 +746,9 @@ "max_temperature": { "name": "Max temperature" }, + "maximum_dimming_level": { + "name": "Maximum dimming level" + }, "maximum_level": { "name": "Maximum load dimming level" }, @@ -728,9 +776,15 @@ "mini_set": { "name": "Liquid minimal percentage" }, + "minimum_dimming_level": { + "name": "Minimum dimming level" + }, "minimum_level": { "name": "Minimum load dimming level" }, + "minimum_on_level": { + "name": "Minimum on level" + }, "motion_detection_sensitivity": { "name": "Motion detection sensitivity" }, @@ -776,6 +830,12 @@ "open_delay_time": { "name": "Open delay time" }, + "open_limit_lift": { + "name": "Open limit lift" + }, + "open_limit_tilt": { + "name": "Open limit tilt" + }, "open_window_detection_guard_period": { "name": "Open window detection guard period" }, @@ -794,6 +854,12 @@ "output_time": { "name": "Output time" }, + "pir_o_to_u_delay": { + "name": "Occupied to unoccupied delay" + }, + "pir_u_to_o_delay": { + "name": "Unoccupied to occupied delay" + }, "portion_weight": { "name": "Portion weight" }, @@ -842,6 +908,9 @@ "sensitivity": { "name": "Sensitivity" }, + "sensitivity_level": { + "name": "Sensitivity level" + }, "serving_size": { "name": "Serving to dispense" }, @@ -873,7 +942,10 @@ "name": "Start-up color temperature" }, "start_up_current_level": { - "name": "Start-up current level" + "name": "Power-on level" + }, + "startup_time": { + "name": "Startup time" }, "state_after_power_restored": { "name": "Start-up default dimming level" @@ -902,21 +974,39 @@ "temperature_sensitivity": { "name": "Temperature sensitivity" }, + "temporary_mode_duration": { + "name": "Temporary mode duration" + }, "tilt_open_close_and_step_time": { "name": "Tilt open close and step time" }, "tilt_position_percentage_after_move_to_level": { "name": "Tilt position percentage after move to level" }, + "tilt_turn_time_close_to_open": { + "name": "Tilt turn time (close to open)" + }, + "tilt_turn_time_open_to_close": { + "name": "Tilt turn time (open to close)" + }, "timer_duration": { "name": "Timer duration" }, + "timer_mode_target_temperature": { + "name": "Timer mode target temperature" + }, "timer_time_left": { "name": "Timer time left" }, "transmit_power": { "name": "Transmit power" }, + "travel_time_close_to_open": { + "name": "Travel time (close to open)" + }, + "travel_time_open_to_close": { + "name": "Travel time (open to close)" + }, "turn_off_delay": { "name": "Turn off delay" }, @@ -935,6 +1025,9 @@ "turn_on_delay_right": { "name": "Turn on delay right" }, + "turnaround_guard_time": { + "name": "Turnaround guard time" + }, "up_movement": { "name": "Up movement" }, @@ -1093,6 +1186,12 @@ "increased_non_neutral_output": { "name": "Increased non-neutral output" }, + "input_mode": { + "name": "Input mode" + }, + "input_mode_id": { + "name": "Input mode {input_id}" + }, "irrigation_mode": { "name": "Irrigation mode" }, @@ -1132,6 +1231,9 @@ "motion_state": { "name": "Motion state" }, + "motor_direction": { + "name": "Motor direction" + }, "motor_thrust": { "name": "Motor thrust" }, @@ -1153,6 +1255,9 @@ "phase": { "name": "Phase" }, + "phase_control": { + "name": "Phase control" + }, "pilot_wire_mode": { "name": "Pilot wire mode" }, @@ -1193,7 +1298,7 @@ "name": "Speed" }, "start_up_on_off": { - "name": "Start-up behavior" + "name": "Power-on behavior" }, "status_indication": { "name": "Status indication" @@ -1234,6 +1339,9 @@ "window_covering_mode": { "name": "Curtain mode" }, + "window_covering_type": { + "name": "Window covering type" + }, "work_mode": { "name": "Work mode" }, @@ -1245,6 +1353,15 @@ "ac_frequency": { "name": "AC frequency" }, + "acceleration_x": { + "name": "Acceleration X" + }, + "acceleration_y": { + "name": "Acceleration Y" + }, + "acceleration_z": { + "name": "Acceleration Z" + }, "active_power_ph_b": { "name": "Power phase B" }, @@ -1275,6 +1392,9 @@ "analog_input": { "name": "Analog input" }, + "auto_calibration_state": { + "name": "Auto-calibration state" + }, "average_light_intensity_20mins": { "name": "Average light intensity last 20 min" }, @@ -1657,6 +1777,9 @@ "adaptation_run_enabled": { "name": "Adaptation run enabled" }, + "adaptive_mode": { + "name": "Adaptive mode" + }, "auto_clean": { "name": "Autoclean" }, @@ -1687,6 +1810,12 @@ "detach_relay": { "name": "Detach relay" }, + "detached": { + "name": "Detached mode" + }, + "detached_id": { + "name": "Detached mode {input_id}" + }, "dimmer_mode": { "name": "Dimmer mode" }, @@ -2009,7 +2138,7 @@ }, "init": { "description": "A backup will be performed and ZHA will be stopped. Do you wish to continue?", - "title": "Reconfigure ZHA" + "title": "Change ZHA adapter settings" }, "intent_migrate": { "description": "Before plugging in your new adapter, your old adapter needs to be reset. An automatic backup will be performed. If you are using a combined Z-Wave and Zigbee adapter like the HUSBZB-1, this will only reset the Zigbee portion.\n\n*Note: if you are migrating from a **ConBee/RaspBee**, make sure it is running firmware `0x26720700` or newer! Otherwise, some devices may not be controllable after migrating until they are power cycled.*\n\nDo you wish to continue?", @@ -2051,16 +2180,16 @@ "title": "[%key:component::zha::config::step::plug_in_old_radio::title%]" }, "prompt_migrate_or_reconfigure": { - "description": "Are you migrating to a new adapter or re-configuring the current adapter?", + "description": "Are you migrating to a new adapter or changing the settings for your current adapter?", "menu_option_descriptions": { "intent_migrate": "This will help you migrate your Zigbee network from your old adapter to a new one.", "intent_reconfigure": "This will let you change the serial port for your current Zigbee adapter." }, "menu_options": { "intent_migrate": "Migrate to a new adapter", - "intent_reconfigure": "Re-configure the current adapter" + "intent_reconfigure": "Change the current adapter's settings" }, - "title": "Migrate or re-configure" + "title": "Migrate or change adapter settings" }, "restore_backup": { "title": "[%key:component::zha::config::step::restore_backup::title%]" diff --git a/homeassistant/components/zhong_hong/climate.py b/homeassistant/components/zhong_hong/climate.py index 69065d1472bc1a..d02c91f77b5e9f 100644 --- a/homeassistant/components/zhong_hong/climate.py +++ b/homeassistant/components/zhong_hong/climate.py @@ -149,6 +149,7 @@ class ZhongHongClimate(ClimateEntity): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) + _attr_target_temperature_step = 1 _attr_temperature_unit = UnitOfTemperature.CELSIUS def __init__(self, hub, addr_out, addr_in): @@ -157,9 +158,9 @@ def __init__(self, hub, addr_out, addr_in): self._device = ZhongHongHVAC(hub, addr_out, addr_in) self._hub = hub self._current_operation = None - self._current_temperature = None - self._target_temperature = None self._current_fan_mode = None + self._attr_unique_id = f"zhong_hong_hvac_{addr_out}_{addr_in}" + self._attr_name = self._attr_unique_id self.is_initialized = False async def async_added_to_hass(self) -> None: @@ -176,23 +177,13 @@ def _after_update(self, climate): self._device.current_operation.lower() ] if self._device.current_temperature: - self._current_temperature = self._device.current_temperature + self._attr_current_temperature = self._device.current_temperature if self._device.current_fan_mode: self._current_fan_mode = self._device.current_fan_mode if self._device.target_temperature: - self._target_temperature = self._device.target_temperature + self._attr_target_temperature = self._device.target_temperature self.schedule_update_ha_state() - @property - def name(self): - """Return the name of the thermostat, if any.""" - return self.unique_id - - @property - def unique_id(self): - """Return the unique ID of the HVAC.""" - return f"zhong_hong_hvac_{self._device.addr_out}_{self._device.addr_in}" - @property def hvac_mode(self) -> HVACMode: """Return current operation ie. heat, cool, idle.""" @@ -201,34 +192,19 @@ def hvac_mode(self) -> HVACMode: return HVACMode.OFF @property - def current_temperature(self): - """Return the current temperature.""" - return self._current_temperature - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._target_temperature - - @property - def target_temperature_step(self): - """Return the supported step of target temperature.""" - return 1 - - @property - def is_on(self): + def is_on(self) -> bool: """Return true if on.""" return self._device.is_on @property - def fan_mode(self): + def fan_mode(self) -> str | None: """Return the fan setting.""" if not self._current_fan_mode: return None return FAN_MODE_REVERSE_MAP.get(self._current_fan_mode, self._current_fan_mode) @property - def fan_modes(self): + def fan_modes(self) -> list[str]: """Return the list of available fan modes.""" if not self._device.fan_list: return [] diff --git a/homeassistant/components/zimi/manifest.json b/homeassistant/components/zimi/manifest.json index 718857c4518ac9..eea74330970025 100644 --- a/homeassistant/components/zimi/manifest.json +++ b/homeassistant/components/zimi/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@markhannon"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zimi", + "integration_type": "hub", "iot_class": "local_push", "quality_scale": "bronze", "requirements": ["zcc-helper==3.7"] diff --git a/homeassistant/components/zinvolt/__init__.py b/homeassistant/components/zinvolt/__init__.py new file mode 100644 index 00000000000000..ff8b7fdfe90c32 --- /dev/null +++ b/homeassistant/components/zinvolt/__init__.py @@ -0,0 +1,51 @@ +"""The Zinvolt integration.""" + +from __future__ import annotations + +import asyncio + +from zinvolt import ZinvoltClient +from zinvolt.exceptions import ZinvoltError + +from homeassistant.const import CONF_ACCESS_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import ZinvoltConfigEntry, ZinvoltDeviceCoordinator + +_PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.NUMBER, + Platform.SENSOR, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: ZinvoltConfigEntry) -> bool: + """Set up Zinvolt from a config entry.""" + session = async_get_clientsession(hass) + client = ZinvoltClient(entry.data[CONF_ACCESS_TOKEN], session=session) + + try: + batteries = await client.get_batteries() + except ZinvoltError as err: + raise ConfigEntryNotReady from err + + coordinators: dict[str, ZinvoltDeviceCoordinator] = {} + tasks = [] + for battery in batteries: + coordinator = ZinvoltDeviceCoordinator(hass, entry, client, battery) + tasks.append(coordinator.async_config_entry_first_refresh()) + coordinators[battery.identifier] = coordinator + await asyncio.gather(*tasks) + + entry.runtime_data = coordinators + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ZinvoltConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/zinvolt/binary_sensor.py b/homeassistant/components/zinvolt/binary_sensor.py new file mode 100644 index 00000000000000..b34fada6ee4e5f --- /dev/null +++ b/homeassistant/components/zinvolt/binary_sensor.py @@ -0,0 +1,112 @@ +"""Binary sensor platform for Zinvolt integration.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import ZinvoltConfigEntry, ZinvoltData, ZinvoltDeviceCoordinator +from .entity import ZinvoltEntity + +POINT_ENTITIES = { + "communication": BinarySensorDeviceClass.PROBLEM, + "voltage": BinarySensorDeviceClass.PROBLEM, + "current": BinarySensorDeviceClass.PROBLEM, + "temperature": BinarySensorDeviceClass.HEAT, + "charge": BinarySensorDeviceClass.PROBLEM, + "discharge": BinarySensorDeviceClass.PROBLEM, + "other": BinarySensorDeviceClass.PROBLEM, +} + + +@dataclass(kw_only=True, frozen=True) +class ZinvoltBatteryStateDescription(BinarySensorEntityDescription): + """Binary sensor description for Zinvolt battery state.""" + + is_on_fn: Callable[[ZinvoltData], bool] + + +SENSORS: tuple[ZinvoltBatteryStateDescription, ...] = ( + ZinvoltBatteryStateDescription( + key="on_grid", + translation_key="on_grid", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + is_on_fn=lambda state: state.battery.current_power.on_grid, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ZinvoltConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Initialize the entries.""" + + entities: list[BinarySensorEntity] = [ + ZinvoltBatteryStateBinarySensor(coordinator, description) + for description in SENSORS + for coordinator in entry.runtime_data.values() + ] + entities.extend( + ZinvoltPointBinarySensor(coordinator, point) + for coordinator in entry.runtime_data.values() + for point in coordinator.data.points + if point in POINT_ENTITIES + ) + async_add_entities(entities) + + +class ZinvoltBatteryStateBinarySensor(ZinvoltEntity, BinarySensorEntity): + """Zinvolt battery state binary sensor.""" + + entity_description: ZinvoltBatteryStateDescription + + def __init__( + self, + coordinator: ZinvoltDeviceCoordinator, + description: ZinvoltBatteryStateDescription, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = ( + f"{coordinator.data.battery.serial_number}.{description.key}" + ) + + @property + def is_on(self) -> bool: + """Return the state of the binary sensor.""" + return self.entity_description.is_on_fn(self.coordinator.data) + + +class ZinvoltPointBinarySensor(ZinvoltEntity, BinarySensorEntity): + """Zinvolt battery state binary sensor.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__(self, coordinator: ZinvoltDeviceCoordinator, point: str) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator) + self.point = point + self._attr_translation_key = point + self._attr_device_class = POINT_ENTITIES[point] + self._attr_unique_id = f"{coordinator.data.battery.serial_number}.{point}" + + @property + def available(self) -> bool: + """Return the availability of the binary sensor.""" + return super().available and self.point in self.coordinator.data.points + + @property + def is_on(self) -> bool: + """Return the state of the binary sensor.""" + return not self.coordinator.data.points[self.point] diff --git a/homeassistant/components/zinvolt/config_flow.py b/homeassistant/components/zinvolt/config_flow.py new file mode 100644 index 00000000000000..f16b26917a4e48 --- /dev/null +++ b/homeassistant/components/zinvolt/config_flow.py @@ -0,0 +1,63 @@ +"""Config flow for the Zinvolt integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +import jwt +import voluptuous as vol +from zinvolt import ZinvoltClient +from zinvolt.exceptions import ZinvoltAuthenticationError, ZinvoltError + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ZinvoltConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Zinvolt.""" + + 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: + session = async_get_clientsession(self.hass) + client = ZinvoltClient(session=session) + try: + token = await client.login( + user_input[CONF_EMAIL], user_input[CONF_PASSWORD] + ) + except ZinvoltAuthenticationError: + errors["base"] = "invalid_auth" + except ZinvoltError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + # Extract the user ID from the JWT token's 'sub' field + decoded_token = jwt.decode(token, options={"verify_signature": False}) + user_id = decoded_token["sub"] + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_EMAIL], data={CONF_ACCESS_TOKEN: token} + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/zinvolt/const.py b/homeassistant/components/zinvolt/const.py new file mode 100644 index 00000000000000..87e3bfd2da15a1 --- /dev/null +++ b/homeassistant/components/zinvolt/const.py @@ -0,0 +1,3 @@ +"""Constants for the Zinvolt integration.""" + +DOMAIN = "zinvolt" diff --git a/homeassistant/components/zinvolt/coordinator.py b/homeassistant/components/zinvolt/coordinator.py new file mode 100644 index 00000000000000..862a4cf8718610 --- /dev/null +++ b/homeassistant/components/zinvolt/coordinator.py @@ -0,0 +1,72 @@ +"""Coordinator for Zinvolt.""" + +from dataclasses import dataclass +from datetime import timedelta +import logging + +from zinvolt import ZinvoltClient +from zinvolt.exceptions import ZinvoltError +from zinvolt.models import Battery, BatteryState + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type ZinvoltConfigEntry = ConfigEntry[dict[str, ZinvoltDeviceCoordinator]] + + +@dataclass +class ZinvoltData: + """Data for the Zinvolt integration.""" + + battery: BatteryState + sw_version: str + model: str + points: dict[str, bool] + + +class ZinvoltDeviceCoordinator(DataUpdateCoordinator[ZinvoltData]): + """Class for Zinvolt devices.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: ZinvoltConfigEntry, + client: ZinvoltClient, + battery: Battery, + ) -> None: + """Initialize the Zinvolt device.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=f"Zinvolt {battery.identifier}", + update_interval=timedelta(minutes=5), + ) + self.battery = battery + self.client = client + + async def _async_update_data(self) -> ZinvoltData: + """Update data from Zinvolt.""" + try: + battery_state = await self.client.get_battery_status( + self.battery.identifier + ) + battery_unit = await self.client.get_battery_unit( + self.battery.identifier, self.battery.serial_number + ) + except ZinvoltError as err: + raise UpdateFailed( + translation_key="update_failed", + translation_domain=DOMAIN, + ) from err + return ZinvoltData( + battery_state, + battery_unit.version.current_version, + battery_unit.battery_model, + {point.point.lower(): point.normal for point in battery_unit.points}, + ) diff --git a/homeassistant/components/zinvolt/diagnostics.py b/homeassistant/components/zinvolt/diagnostics.py new file mode 100644 index 00000000000000..40e2dc49d5c4ff --- /dev/null +++ b/homeassistant/components/zinvolt/diagnostics.py @@ -0,0 +1,24 @@ +"""Diagnostics support for Zinvolt.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from homeassistant.core import HomeAssistant + +from .coordinator import ZinvoltConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ZinvoltConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return { + "coordinators": [ + { + coordinator.battery.identifier: asdict(coordinator.data), + } + for coordinator in entry.runtime_data.values() + ], + } diff --git a/homeassistant/components/zinvolt/entity.py b/homeassistant/components/zinvolt/entity.py new file mode 100644 index 00000000000000..a9e9a2c89df1b3 --- /dev/null +++ b/homeassistant/components/zinvolt/entity.py @@ -0,0 +1,25 @@ +"""Base entity for Zinvolt integration.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ZinvoltDeviceCoordinator + + +class ZinvoltEntity(CoordinatorEntity[ZinvoltDeviceCoordinator]): + """Base entity for Zinvolt integration.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: ZinvoltDeviceCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.data.battery.serial_number)}, + manufacturer="Zinvolt", + name=coordinator.battery.name, + serial_number=coordinator.data.battery.serial_number, + model_id=coordinator.data.model, + sw_version=coordinator.data.sw_version, + ) diff --git a/homeassistant/components/zinvolt/manifest.json b/homeassistant/components/zinvolt/manifest.json new file mode 100644 index 00000000000000..c0be07030c60b2 --- /dev/null +++ b/homeassistant/components/zinvolt/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "zinvolt", + "name": "Zinvolt", + "codeowners": ["@joostlek"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/zinvolt", + "integration_type": "hub", + "iot_class": "cloud_polling", + "loggers": ["zinvolt"], + "quality_scale": "bronze", + "requirements": ["zinvolt==0.3.0"] +} diff --git a/homeassistant/components/zinvolt/number.py b/homeassistant/components/zinvolt/number.py new file mode 100644 index 00000000000000..5fcc7e01199be8 --- /dev/null +++ b/homeassistant/components/zinvolt/number.py @@ -0,0 +1,132 @@ +"""Number platform for Zinvolt integration.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from zinvolt import ZinvoltClient + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfPower, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import ZinvoltConfigEntry, ZinvoltData, ZinvoltDeviceCoordinator +from .entity import ZinvoltEntity + + +@dataclass(kw_only=True, frozen=True) +class ZinvoltBatteryStateDescription(NumberEntityDescription): + """Number description for Zinvolt battery state.""" + + max_fn: Callable[[ZinvoltData], int] | None = None + value_fn: Callable[[ZinvoltData], int] + set_value_fn: Callable[[ZinvoltClient, str, int], Awaitable[None]] + + +NUMBERS: tuple[ZinvoltBatteryStateDescription, ...] = ( + ZinvoltBatteryStateDescription( + key="max_output", + translation_key="max_output", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + value_fn=lambda state: state.battery.global_settings.max_output, + set_value_fn=lambda client, battery_id, value: client.set_max_output( + battery_id, value + ), + native_min_value=0, + max_fn=lambda state: state.battery.global_settings.max_output_limit, + ), + ZinvoltBatteryStateDescription( + key="upper_threshold", + translation_key="upper_threshold", + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda state: state.battery.global_settings.battery_upper_threshold, + set_value_fn=lambda client, battery_id, value: client.set_upper_threshold( + battery_id, value + ), + native_min_value=0, + native_max_value=100, + ), + ZinvoltBatteryStateDescription( + key="lower_threshold", + translation_key="lower_threshold", + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda state: state.battery.global_settings.battery_lower_threshold, + set_value_fn=lambda client, battery_id, value: client.set_lower_threshold( + battery_id, value + ), + native_min_value=9, + native_max_value=100, + ), + ZinvoltBatteryStateDescription( + key="standby_time", + translation_key="standby_time", + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=NumberDeviceClass.DURATION, + value_fn=lambda state: state.battery.global_settings.standby_time, + set_value_fn=lambda client, battery_id, value: client.set_standby_time( + battery_id, value + ), + native_min_value=5, + native_max_value=60, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ZinvoltConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Initialize the entries.""" + + async_add_entities( + ZinvoltBatteryStateNumber(coordinator, description) + for description in NUMBERS + for coordinator in entry.runtime_data.values() + ) + + +class ZinvoltBatteryStateNumber(ZinvoltEntity, NumberEntity): + """Zinvolt number.""" + + entity_description: ZinvoltBatteryStateDescription + + def __init__( + self, + coordinator: ZinvoltDeviceCoordinator, + description: ZinvoltBatteryStateDescription, + ) -> None: + """Initialize the number.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = ( + f"{coordinator.data.battery.serial_number}.{description.key}" + ) + + @property + def native_max_value(self) -> float: + """Return the native maximum value.""" + if self.entity_description.max_fn is None: + return super().native_max_value + return self.entity_description.max_fn(self.coordinator.data) + + @property + def native_value(self) -> float: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) + + async def async_set_native_value(self, value: float) -> None: + """Set the state of the sensor.""" + await self.entity_description.set_value_fn( + self.coordinator.client, self.coordinator.battery.identifier, int(value) + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/zinvolt/quality_scale.yaml b/homeassistant/components/zinvolt/quality_scale.yaml new file mode 100644 index 00000000000000..9a86dcb68ea347 --- /dev/null +++ b/homeassistant/components/zinvolt/quality_scale.yaml @@ -0,0 +1,70 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: There are no 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: There are no custom actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Entities do not explicitly subscribe to events + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: There are no configuration parameters + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: done + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: There are no repairable issues + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/zinvolt/sensor.py b/homeassistant/components/zinvolt/sensor.py new file mode 100644 index 00000000000000..58633cf78dc539 --- /dev/null +++ b/homeassistant/components/zinvolt/sensor.py @@ -0,0 +1,79 @@ +"""Sensor platform for Zinvolt integration.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE, UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import ZinvoltConfigEntry, ZinvoltData, ZinvoltDeviceCoordinator +from .entity import ZinvoltEntity + + +@dataclass(kw_only=True, frozen=True) +class ZinvoltBatteryStateDescription(SensorEntityDescription): + """Sensor description for Zinvolt battery state.""" + + value_fn: Callable[[ZinvoltData], float] + + +SENSORS: tuple[ZinvoltBatteryStateDescription, ...] = ( + ZinvoltBatteryStateDescription( + key="state_of_charge", + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda state: state.battery.current_power.state_of_charge, + ), + ZinvoltBatteryStateDescription( + key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_fn=lambda state: 0 - state.battery.current_power.power_socket_output, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ZinvoltConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Initialize the entries.""" + + async_add_entities( + ZinvoltBatteryStateSensor(coordinator, description) + for description in SENSORS + for coordinator in entry.runtime_data.values() + ) + + +class ZinvoltBatteryStateSensor(ZinvoltEntity, SensorEntity): + """Zinvolt battery state sensor.""" + + entity_description: ZinvoltBatteryStateDescription + + def __init__( + self, + coordinator: ZinvoltDeviceCoordinator, + description: ZinvoltBatteryStateDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = ( + f"{coordinator.data.battery.serial_number}.{description.key}" + ) + + @property + def native_value(self) -> float: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/zinvolt/strings.json b/homeassistant/components/zinvolt/strings.json new file mode 100644 index 00000000000000..d4bc22a1247fde --- /dev/null +++ b/homeassistant/components/zinvolt/strings.json @@ -0,0 +1,71 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "initiate_flow": { + "user": "[%key:common::config_flow::initiate_flow::account%]" + }, + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "The email of your Zinvolt account.", + "password": "The password of your Zinvolt account." + } + } + } + }, + "entity": { + "binary_sensor": { + "charge": { + "name": "Charge" + }, + "communication": { + "name": "Communication" + }, + "current": { + "name": "Current" + }, + "discharge": { + "name": "Discharge" + }, + "on_grid": { + "name": "Grid connection" + }, + "other": { + "name": "Other problems" + }, + "voltage": { + "name": "Voltage" + } + }, + "number": { + "lower_threshold": { + "name": "Minimum charge level" + }, + "max_output": { + "name": "Maximum output" + }, + "standby_time": { + "name": "Standby time" + }, + "upper_threshold": { + "name": "Maximum charge level" + } + } + }, + "exceptions": { + "update_failed": { + "message": "An error occurred while updating the Zinvolt integration." + } + } +} diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index 6325f830ea0610..b0d7a6ba8d1819 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -269,8 +269,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def reload_service_handler(service_call: ServiceCall) -> None: """Remove all zones and load new ones from config.""" conf = await component.async_prepare_reload(skip_reset=True) - if conf is None: - return await yaml_collection.async_load(conf[DOMAIN]) service.async_register_admin_service( diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 6c6c19cf8769a1..aa3adf46de834f 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -9,7 +9,6 @@ from typing import Any from awesomeversion import AwesomeVersion -import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import CommandClass, RemoveNodeReason from zwave_js_server.exceptions import ( @@ -94,7 +93,6 @@ CONF_ADDON_S2_UNAUTHENTICATED_KEY, CONF_ADDON_SOCKET, CONF_DATA_COLLECTION_OPTED_IN, - CONF_INSTALLER_MODE, CONF_INTEGRATION_CREATED_ADDON, CONF_KEEP_OLD_DEVICES, CONF_LR_S2_ACCESS_CONTROL_KEY, @@ -138,16 +136,8 @@ CONNECT_TIMEOUT = 10 DRIVER_READY_TIMEOUT = 60 -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Optional(CONF_INSTALLER_MODE, default=False): cv.boolean, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + MIN_CONTROLLER_FIRMWARE_SDK_VERSION = AwesomeVersion("6.50.0") PLATFORMS = [ @@ -171,7 +161,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Z-Wave JS component.""" - hass.data[DOMAIN] = config.get(DOMAIN, {}) for entry in hass.config_entries.async_entries(DOMAIN): if not isinstance(entry.unique_id, str): hass.config_entries.async_update_entry( diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index b392b1c95cdde1..2388cc085faf60 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -84,7 +84,6 @@ ATTR_PARAMETERS, ATTR_WAIT_FOR_RESULT, CONF_DATA_COLLECTION_OPTED_IN, - CONF_INSTALLER_MODE, DOMAIN, EVENT_DEVICE_ADDED_TO_REGISTRY, LOGGER, @@ -476,7 +475,6 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_hard_reset_controller) websocket_api.async_register_command(hass, websocket_node_capabilities) websocket_api.async_register_command(hass, websocket_invoke_cc_api) - websocket_api.async_register_command(hass, websocket_get_integration_settings) websocket_api.async_register_command(hass, websocket_backup_nvm) websocket_api.async_register_command(hass, websocket_restore_nvm) hass.http.register_view(FirmwareUploadView(dr.async_get(hass))) @@ -2965,28 +2963,6 @@ async def websocket_invoke_cc_api( ) -@callback -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zwave_js/get_integration_settings", - } -) -def websocket_get_integration_settings( - hass: HomeAssistant, - connection: ActiveConnection, - msg: dict[str, Any], -) -> None: - """Get Z-Wave JS integration wide configuration.""" - connection.send_result( - msg[ID], - { - # list explicitly to avoid leaking other keys and to set default - CONF_INSTALLER_MODE: hass.data[DOMAIN].get(CONF_INSTALLER_MODE, False), - }, - ) - - @websocket_api.require_admin @websocket_api.websocket_command( { diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index f53b670ae46d1e..cf207338bfeec4 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -2,13 +2,16 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass, field +from enum import IntEnum from typing import TYPE_CHECKING, cast from zwave_js_server.const import CommandClass from zwave_js_server.const.command_class.lock import DOOR_STATUS_PROPERTY from zwave_js_server.const.command_class.notification import ( CC_SPECIFIC_NOTIFICATION_TYPE, + AccessControlNotificationEvent, NotificationEvent, NotificationType, SmokeAlarmNotificationEvent, @@ -29,6 +32,10 @@ from .const import DOMAIN from .entity import NewZwaveDiscoveryInfo, ZWaveBaseEntity +from .helpers import ( + get_opening_state_notification_value, + is_opening_state_notification_value, +) from .models import ( NewZWaveDiscoverySchema, ValueType, @@ -59,6 +66,42 @@ NOTIFICATION_IRRIGATION = "17" NOTIFICATION_GAS = "18" +# Deprecated/legacy synthetic Access Control door state notification +# event IDs that don't exist in zwave-js-server +ACCESS_CONTROL_DOOR_STATE_OPEN_REGULAR = 5632 +ACCESS_CONTROL_DOOR_STATE_OPEN_TILT = 5633 + + +# Numeric State values used by the "Opening state" notification variable. +# This is only needed temporarily until the legacy Access Control door state binary sensors are removed. +class OpeningState(IntEnum): + """Opening state values exposed by Access Control notifications.""" + + CLOSED = 0 + OPEN = 1 + TILTED = 2 + + +# parse_opening_state helpers for the DEPRECATED legacy Access Control binary sensors. +def _legacy_is_closed(opening_state: OpeningState) -> bool: + """Return if Opening state represents closed.""" + return opening_state is OpeningState.CLOSED + + +def _legacy_is_open(opening_state: OpeningState) -> bool: + """Return if Opening state represents open.""" + return opening_state is OpeningState.OPEN + + +def _legacy_is_open_or_tilted(opening_state: OpeningState) -> bool: + """Return if Opening state represents open or tilted.""" + return opening_state in (OpeningState.OPEN, OpeningState.TILTED) + + +def _legacy_is_tilted(opening_state: OpeningState) -> bool: + """Return if Opening state represents tilted.""" + return opening_state is OpeningState.TILTED + @dataclass(frozen=True, kw_only=True) class NotificationZWaveJSEntityDescription(BinarySensorEntityDescription): @@ -82,6 +125,14 @@ class NewNotificationZWaveJSEntityDescription(BinarySensorEntityDescription): state_key: str +@dataclass(frozen=True, kw_only=True) +class OpeningStateZWaveJSEntityDescription(BinarySensorEntityDescription): + """Describe a legacy Access Control binary sensor that derives state from Opening state.""" + + state_key: int + parse_opening_state: Callable[[OpeningState], bool] + + # Mappings for Notification sensors # https://github.com/zwave-js/specs/blob/master/Registries/Notification%20Command%20Class%2C%20list%20of%20assigned%20Notifications.xlsx # @@ -127,6 +178,7 @@ class NewNotificationZWaveJSEntityDescription(BinarySensorEntityDescription): # to use the new discovery schema and we've removed the old discovery code. MIGRATED_NOTIFICATION_TYPES = { NotificationType.SMOKE_ALARM, + NotificationType.ACCESS_CONTROL, } NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = ( @@ -202,26 +254,6 @@ class NewNotificationZWaveJSEntityDescription(BinarySensorEntityDescription): key=NOTIFICATION_WATER, entity_category=EntityCategory.DIAGNOSTIC, ), - NotificationZWaveJSEntityDescription( - # NotificationType 6: Access Control - State Id's 1, 2, 3, 4 (Lock) - key=NOTIFICATION_ACCESS_CONTROL, - states={1, 2, 3, 4}, - device_class=BinarySensorDeviceClass.LOCK, - ), - NotificationZWaveJSEntityDescription( - # NotificationType 6: Access Control - State Id's 11 (Lock jammed) - key=NOTIFICATION_ACCESS_CONTROL, - states={11}, - device_class=BinarySensorDeviceClass.PROBLEM, - entity_category=EntityCategory.DIAGNOSTIC, - ), - NotificationZWaveJSEntityDescription( - # NotificationType 6: Access Control - State Id 22 (door/window open) - key=NOTIFICATION_ACCESS_CONTROL, - not_states={23}, - states={22}, - device_class=BinarySensorDeviceClass.DOOR, - ), NotificationZWaveJSEntityDescription( # NotificationType 7: Home Security - State Id's 1, 2 (intrusion) key=NOTIFICATION_HOME_SECURITY, @@ -364,6 +396,10 @@ def is_valid_notification_binary_sensor( """Return if the notification CC Value is valid as binary sensor.""" if not info.primary_value.metadata.states: return False + # Access Control - Opening state is exposed as a single enum sensor instead + # of fanning out one binary sensor per state. + if is_opening_state_notification_value(info.primary_value): + return False return len(info.primary_value.metadata.states) > 1 @@ -401,6 +437,18 @@ def async_add_binary_sensor( or int(state_key) in info.entity_description.states ) ) + elif ( + isinstance(info, NewZwaveDiscoveryInfo) + and info.entity_class is ZWaveBooleanBinarySensor + ): + entities.append(ZWaveBooleanBinarySensor(config_entry, driver, info)) + elif ( + isinstance(info, NewZwaveDiscoveryInfo) + and info.entity_class is ZWaveLegacyDoorStateBinarySensor + ): + entities.append( + ZWaveLegacyDoorStateBinarySensor(config_entry, driver, info) + ) elif isinstance(info, NewZwaveDiscoveryInfo): pass # other entity classes are not migrated yet elif info.platform_hint == "notification": @@ -481,12 +529,16 @@ def __init__( self, config_entry: ZwaveJSConfigEntry, driver: Driver, - info: ZwaveDiscoveryInfo, + info: ZwaveDiscoveryInfo | NewZwaveDiscoveryInfo, ) -> None: """Initialize a ZWaveBooleanBinarySensor entity.""" super().__init__(config_entry, driver, info) - # Entity class attributes + if isinstance(info, NewZwaveDiscoveryInfo): + # Entity name and description are set from the discovery schema. + return + + # Entity class attributes for old-style discovery. self._attr_name = self.generate_name(include_value_name=True) primary_value = self.info.primary_value if description := BOOLEAN_SENSOR_MAPPINGS.get( @@ -533,6 +585,51 @@ def is_on(self) -> bool | None: return int(self.info.primary_value.value) == int(self.state_key) +class ZWaveLegacyDoorStateBinarySensor(ZWaveBaseEntity, BinarySensorEntity): + """DEPRECATED: Legacy door state binary sensors. + + These entities exist purely for backwards compatibility with users who had + door state binary sensors before the Opening state value was introduced. + They are disabled by default when the Opening state value is present and + should not be extended. State is derived from the Opening state notification + value using the parse_opening_state function defined on the entity description. + """ + + entity_description: OpeningStateZWaveJSEntityDescription + + def __init__( + self, + config_entry: ZwaveJSConfigEntry, + driver: Driver, + info: NewZwaveDiscoveryInfo, + ) -> None: + """Initialize a legacy Door state binary sensor entity.""" + super().__init__(config_entry, driver, info) + opening_state_value = get_opening_state_notification_value(self.info.node) + assert opening_state_value is not None # guaranteed by required_values schema + self._opening_state_value_id = opening_state_value.value_id + self.watched_value_ids.add(opening_state_value.value_id) + self._attr_unique_id = ( + f"{self._attr_unique_id}.{self.entity_description.state_key}" + ) + + @property + def is_on(self) -> bool | None: + """Return if the sensor is on or off.""" + value = self.info.node.values.get(self._opening_state_value_id) + if value is None: + return None + opening_state = value.value + if opening_state is None: + return None + try: + return self.entity_description.parse_opening_state( + OpeningState(int(opening_state)) + ) + except TypeError, ValueError: + return None + + class ZWavePropertyBinarySensor(ZWaveBaseEntity, BinarySensorEntity): """Representation of a Z-Wave binary_sensor from a property.""" @@ -577,7 +674,413 @@ def __init__( ) +OPENING_STATE_NOTIFICATION_SCHEMA = ZWaveValueDiscoverySchema( + command_class={CommandClass.NOTIFICATION}, + property={"Access Control"}, + property_key={"Opening state"}, + type={ValueType.NUMBER}, + any_available_cc_specific={ + (CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL) + }, +) + + DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ + NewZWaveDiscoverySchema( + platform=Platform.BINARY_SENSOR, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.NOTIFICATION}, + property={"Access Control"}, + property_key={"Lock state"}, + type={ValueType.NUMBER}, + any_available_states_keys={1, 2, 3, 4}, + any_available_cc_specific={ + (CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL) + }, + ), + allow_multi=True, + entity_description=NotificationZWaveJSEntityDescription( + # NotificationType 6: Access Control - State Id's 1, 2, 3, 4 (Lock) + key=NOTIFICATION_ACCESS_CONTROL, + states={1, 2, 3, 4}, + device_class=BinarySensorDeviceClass.LOCK, + ), + entity_class=ZWaveNotificationBinarySensor, + ), + NewZWaveDiscoverySchema( + platform=Platform.BINARY_SENSOR, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.NOTIFICATION}, + property={"Access Control"}, + property_key={"Lock state"}, + type={ValueType.NUMBER}, + any_available_states_keys={11}, + any_available_cc_specific={ + (CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL) + }, + ), + entity_description=NotificationZWaveJSEntityDescription( + # NotificationType 6: Access Control - State Id's 11 (Lock jammed) + key=NOTIFICATION_ACCESS_CONTROL, + states={11}, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + entity_class=ZWaveNotificationBinarySensor, + ), + # ------------------------------------------------------------------- + # DEPRECATED legacy Access Control door/window binary sensors. + # These schemas exist only for backwards compatibility with users who + # already have these entities registered. New integrations should use + # the Opening state enum sensor instead. Do not add new schemas here. + # All schemas below use ZWaveLegacyDoorStateBinarySensor and are + # disabled by default (entity_registry_enabled_default=False). + # ------------------------------------------------------------------- + NewZWaveDiscoverySchema( + platform=Platform.BINARY_SENSOR, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.NOTIFICATION}, + property={"Access Control"}, + property_key={"Door state (simple)"}, + type={ValueType.NUMBER}, + any_available_states_keys={ + AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN + }, + any_available_cc_specific={ + (CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL) + }, + ), + required_values=[OPENING_STATE_NOTIFICATION_SCHEMA], + allow_multi=True, + entity_description=OpeningStateZWaveJSEntityDescription( + key="legacy_access_control_door_state_simple_open", + name="Window/door is open", + state_key=AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN, + parse_opening_state=_legacy_is_open_or_tilted, + device_class=BinarySensorDeviceClass.DOOR, + entity_registry_enabled_default=False, + ), + entity_class=ZWaveLegacyDoorStateBinarySensor, + ), + NewZWaveDiscoverySchema( + platform=Platform.BINARY_SENSOR, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.NOTIFICATION}, + property={"Access Control"}, + property_key={"Door state (simple)"}, + type={ValueType.NUMBER}, + any_available_states_keys={ + AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_CLOSED + }, + any_available_cc_specific={ + (CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL) + }, + ), + required_values=[OPENING_STATE_NOTIFICATION_SCHEMA], + allow_multi=True, + entity_description=OpeningStateZWaveJSEntityDescription( + key="legacy_access_control_door_state_simple_closed", + name="Window/door is closed", + state_key=AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_CLOSED, + parse_opening_state=_legacy_is_closed, + entity_registry_enabled_default=False, + ), + entity_class=ZWaveLegacyDoorStateBinarySensor, + ), + NewZWaveDiscoverySchema( + platform=Platform.BINARY_SENSOR, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.NOTIFICATION}, + property={"Access Control"}, + property_key={"Door state"}, + type={ValueType.NUMBER}, + any_available_states_keys={ + AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN + }, + any_available_cc_specific={ + (CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL) + }, + ), + required_values=[OPENING_STATE_NOTIFICATION_SCHEMA], + allow_multi=True, + entity_description=OpeningStateZWaveJSEntityDescription( + key="legacy_access_control_door_state_open", + name="Window/door is open", + state_key=AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN, + parse_opening_state=_legacy_is_open, + device_class=BinarySensorDeviceClass.DOOR, + entity_registry_enabled_default=False, + ), + entity_class=ZWaveLegacyDoorStateBinarySensor, + ), + NewZWaveDiscoverySchema( + platform=Platform.BINARY_SENSOR, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.NOTIFICATION}, + property={"Access Control"}, + property_key={"Door state"}, + type={ValueType.NUMBER}, + any_available_states_keys={ + AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_CLOSED + }, + any_available_cc_specific={ + (CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL) + }, + ), + required_values=[OPENING_STATE_NOTIFICATION_SCHEMA], + allow_multi=True, + entity_description=OpeningStateZWaveJSEntityDescription( + key="legacy_access_control_door_state_closed", + name="Window/door is closed", + state_key=AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_CLOSED, + parse_opening_state=_legacy_is_closed, + entity_registry_enabled_default=False, + ), + entity_class=ZWaveLegacyDoorStateBinarySensor, + ), + NewZWaveDiscoverySchema( + platform=Platform.BINARY_SENSOR, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.NOTIFICATION}, + property={"Access Control"}, + property_key={"Door state"}, + type={ValueType.NUMBER}, + any_available_states_keys={ACCESS_CONTROL_DOOR_STATE_OPEN_REGULAR}, + any_available_cc_specific={ + (CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL) + }, + ), + required_values=[OPENING_STATE_NOTIFICATION_SCHEMA], + allow_multi=True, + entity_description=OpeningStateZWaveJSEntityDescription( + key="legacy_access_control_door_state_open_regular", + name="Window/door is open in regular position", + state_key=ACCESS_CONTROL_DOOR_STATE_OPEN_REGULAR, + parse_opening_state=_legacy_is_open, + entity_registry_enabled_default=False, + ), + entity_class=ZWaveLegacyDoorStateBinarySensor, + ), + NewZWaveDiscoverySchema( + platform=Platform.BINARY_SENSOR, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.NOTIFICATION}, + property={"Access Control"}, + property_key={"Door state"}, + type={ValueType.NUMBER}, + any_available_states_keys={ACCESS_CONTROL_DOOR_STATE_OPEN_TILT}, + any_available_cc_specific={ + (CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL) + }, + ), + required_values=[OPENING_STATE_NOTIFICATION_SCHEMA], + allow_multi=True, + entity_description=OpeningStateZWaveJSEntityDescription( + key="legacy_access_control_door_state_open_tilt", + name="Window/door is open in tilt position", + state_key=ACCESS_CONTROL_DOOR_STATE_OPEN_TILT, + parse_opening_state=_legacy_is_tilted, + entity_registry_enabled_default=False, + ), + entity_class=ZWaveLegacyDoorStateBinarySensor, + ), + NewZWaveDiscoverySchema( + platform=Platform.BINARY_SENSOR, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.NOTIFICATION}, + property={"Access Control"}, + property_key={"Door tilt state"}, + type={ValueType.NUMBER}, + any_available_states_keys={OpeningState.OPEN}, + any_available_cc_specific={ + (CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL) + }, + ), + required_values=[OPENING_STATE_NOTIFICATION_SCHEMA], + allow_multi=True, + entity_description=OpeningStateZWaveJSEntityDescription( + key="legacy_access_control_door_tilt_state_tilted", + name="Window/door is tilted", + state_key=OpeningState.OPEN, + parse_opening_state=_legacy_is_tilted, + entity_registry_enabled_default=False, + ), + entity_class=ZWaveLegacyDoorStateBinarySensor, + ), + # ------------------------------------------------------------------- + # Access Control door/window binary sensors for devices that do NOT have the + # new "Opening state" notification value. These replace the old-style discovery + # that used NOTIFICATION_SENSOR_MAPPINGS. + # + # Each property_key uses two schemas so that only the "open" state entity gets + # device_class=DOOR, while the other state entities (e.g. "closed") do not. + # The first schema uses allow_multi=True so it does not consume the value, allowing + # the second schema to also match and create entities for the remaining states. + NewZWaveDiscoverySchema( + platform=Platform.BINARY_SENSOR, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.NOTIFICATION}, + property={"Access Control"}, + property_key={"Door state (simple)"}, + type={ValueType.NUMBER}, + any_available_states_keys={ + AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN + }, + any_available_cc_specific={ + (CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL) + }, + ), + absent_values=[OPENING_STATE_NOTIFICATION_SCHEMA], + allow_multi=True, + entity_description=NotificationZWaveJSEntityDescription( + key=NOTIFICATION_ACCESS_CONTROL, + states={AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN}, + device_class=BinarySensorDeviceClass.DOOR, + ), + entity_class=ZWaveNotificationBinarySensor, + ), + NewZWaveDiscoverySchema( + platform=Platform.BINARY_SENSOR, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.NOTIFICATION}, + property={"Access Control"}, + property_key={"Door state (simple)"}, + type={ValueType.NUMBER}, + any_available_states_keys={ + AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN + }, + any_available_cc_specific={ + (CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL) + }, + ), + absent_values=[OPENING_STATE_NOTIFICATION_SCHEMA], + entity_description=NotificationZWaveJSEntityDescription( + key=NOTIFICATION_ACCESS_CONTROL, + not_states={AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN}, + ), + entity_class=ZWaveNotificationBinarySensor, + ), + NewZWaveDiscoverySchema( + platform=Platform.BINARY_SENSOR, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.NOTIFICATION}, + property={"Access Control"}, + property_key={"Door state"}, + type={ValueType.NUMBER}, + any_available_states_keys={ + AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN + }, + any_available_cc_specific={ + (CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL) + }, + ), + absent_values=[OPENING_STATE_NOTIFICATION_SCHEMA], + allow_multi=True, + entity_description=NotificationZWaveJSEntityDescription( + key=NOTIFICATION_ACCESS_CONTROL, + states={AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN}, + device_class=BinarySensorDeviceClass.DOOR, + ), + entity_class=ZWaveNotificationBinarySensor, + ), + NewZWaveDiscoverySchema( + platform=Platform.BINARY_SENSOR, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.NOTIFICATION}, + property={"Access Control"}, + property_key={"Door state"}, + type={ValueType.NUMBER}, + any_available_states_keys={ + AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN + }, + any_available_cc_specific={ + (CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL) + }, + ), + absent_values=[OPENING_STATE_NOTIFICATION_SCHEMA], + entity_description=NotificationZWaveJSEntityDescription( + key=NOTIFICATION_ACCESS_CONTROL, + not_states={AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN}, + ), + entity_class=ZWaveNotificationBinarySensor, + ), + NewZWaveDiscoverySchema( + platform=Platform.BINARY_SENSOR, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.NOTIFICATION}, + property={"Access Control"}, + property_key={"Door tilt state"}, + type={ValueType.NUMBER}, + any_available_states_keys={OpeningState.OPEN}, + any_available_cc_specific={ + (CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL) + }, + ), + absent_values=[OPENING_STATE_NOTIFICATION_SCHEMA], + entity_description=NotificationZWaveJSEntityDescription( + key=NOTIFICATION_ACCESS_CONTROL, + states={OpeningState.OPEN}, + ), + entity_class=ZWaveNotificationBinarySensor, + ), + NewZWaveDiscoverySchema( + platform=Platform.BINARY_SENSOR, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.NOTIFICATION}, + property={"Access Control"}, + type={ValueType.NUMBER}, + any_available_cc_specific={ + (CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL) + }, + ), + allow_multi=True, + entity_description=NotificationZWaveJSEntityDescription( + # NotificationType 6: Access Control - All other notification values. + # not_states excludes states already handled by more specific schemas above, + # so this catch-all only fires for genuinely unhandled property keys + # (e.g. barrier, keypad, credential events). + key=NOTIFICATION_ACCESS_CONTROL, + entity_category=EntityCategory.DIAGNOSTIC, + not_states={ + 0, + # Lock state values (Lock state schemas consume the value when state 11 is + # available, but may not when state 11 is absent) + 1, + 2, + 3, + 4, + 11, + # Door state (simple) / Door state values + AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN, + AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_CLOSED, + ACCESS_CONTROL_DOOR_STATE_OPEN_REGULAR, + ACCESS_CONTROL_DOOR_STATE_OPEN_TILT, + }, + ), + entity_class=ZWaveNotificationBinarySensor, + ), + # ------------------------------------------------------------------- + NewZWaveDiscoverySchema( + # Hoppe eHandle ConnectSense (0x0313:0x0701:0x0002) - window tilt sensor. + # The window tilt state is exposed as a binary sensor that is disabled by default + # instead of a notification sensor. We enable that sensor and give it a name + # that is more consistent with the other window related entities. + platform=Platform.BINARY_SENSOR, + manufacturer_id={0x0313}, + product_id={0x0002}, + product_type={0x0701}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SENSOR_BINARY}, + property={"Tilt"}, + type={ValueType.BOOLEAN}, + ), + entity_description=BinarySensorEntityDescription( + key="window_door_is_tilted", + name="Window/door is tilted", + device_class=BinarySensorDeviceClass.WINDOW, + ), + entity_class=ZWaveBooleanBinarySensor, + ), NewZWaveDiscoverySchema( platform=Platform.BINARY_SENSOR, primary_value=ZWaveValueDiscoverySchema( diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index ce2710ec65214f..a24c88e725df26 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -25,7 +25,6 @@ CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY = "lr_s2_access_control_key" CONF_ADDON_LR_S2_AUTHENTICATED_KEY = "lr_s2_authenticated_key" CONF_ADDON_SOCKET = "socket" -CONF_INSTALLER_MODE = "installer_mode" CONF_INTEGRATION_CREATED_ADDON = "integration_created_addon" CONF_KEEP_OLD_DEVICES = "keep_old_devices" CONF_NETWORK_KEY = "network_key" @@ -207,3 +206,7 @@ WindowCoveringPropertyKey.VERTICAL_SLATS_ANGLE, WindowCoveringPropertyKey.VERTICAL_SLATS_ANGLE_NO_POSITION, } + +# notification +NOTIFICATION_ACCESS_CONTROL_PROPERTY = "Access Control" +OPENING_STATE_PROPERTY_KEY = "Opening state" diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index d468a233f05000..4f5379684226b0 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -6,8 +6,11 @@ from zwave_js_server.const import ( CURRENT_VALUE_PROPERTY, + SET_VALUE_SUCCESS, TARGET_STATE_PROPERTY, TARGET_VALUE_PROPERTY, + CommandClass, + SetValueStatus, ) from zwave_js_server.const.command_class.barrier_operator import BarrierState from zwave_js_server.const.command_class.multilevel_switch import ( @@ -85,6 +88,8 @@ class CoverPositionMixin(ZWaveBaseEntity, CoverEntity): _current_position_value: ZwaveValue | None = None _target_position_value: ZwaveValue | None = None _stop_position_value: ZwaveValue | None = None + # Remember whether the moving state can be tracked reliably for this device. + _moving_state_disabled: bool = False def _set_position_values( self, @@ -145,6 +150,23 @@ def is_closed(self) -> bool | None: return None return bool(value.value == self._fully_closed_position) + @callback + def on_value_update(self) -> None: + """Clear moving state when current position reaches target.""" + if not self._attr_is_opening and not self._attr_is_closing: + return + + if (current := self._current_position_value) is None or current.value is None: + return + + if ( + (t := self._target_position_value) is not None + and t.value is not None + and current.value == t.value + ): + self._attr_is_opening = False + self._attr_is_closing = False + @property def current_cover_position(self) -> int | None: """Return the current position of cover where 0 means closed and 100 is fully open.""" @@ -156,33 +178,70 @@ def current_cover_position(self) -> int | None: return None return self.zwave_to_percent_position(self._current_position_value.value) + async def _async_set_position_and_update_moving_state( + self, target_position: int + ) -> None: + """Set the target position and update the moving state if applicable.""" + assert self._target_position_value + result = await self._async_set_value( + self._target_position_value, target_position + ) + if ( + self._moving_state_disabled + # If the command is unsupervised, or the device reported that it started + # working, we can assume the cover is moving in the desired direction. + or result is None + or result.status + not in (SetValueStatus.WORKING, SetValueStatus.SUCCESS_UNSUPERVISED) + # If we don't know the current position, we don't know which direction + # the cover is moving, so we can't update the moving state. + or (current_value := self._current_position_value) is None + or (current := current_value.value) is None + ): + return + + if target_position > current: + self._attr_is_opening = True + self._attr_is_closing = False + elif target_position < current: + self._attr_is_opening = False + self._attr_is_closing = True + else: + return + + self.async_write_ha_state() + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" - assert self._target_position_value - await self._async_set_value( - self._target_position_value, - self.percent_to_zwave_position(kwargs[ATTR_POSITION]), + await self._async_set_position_and_update_moving_state( + self.percent_to_zwave_position(kwargs[ATTR_POSITION]) ) async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - assert self._target_position_value - await self._async_set_value( - self._target_position_value, self._fully_open_position + await self._async_set_position_and_update_moving_state( + self._fully_open_position ) async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" - assert self._target_position_value - await self._async_set_value( - self._target_position_value, self._fully_closed_position + await self._async_set_position_and_update_moving_state( + self._fully_closed_position ) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop cover.""" assert self._stop_position_value # Stop the cover, will stop regardless of the actual direction of travel. - await self._async_set_value(self._stop_position_value, False) + result = await self._async_set_value(self._stop_position_value, False) + # When stopping is successful (or unsupervised), we can assume the cover has stopped moving. + if result is not None and result.status in ( + SetValueStatus.SUCCESS, + SetValueStatus.SUCCESS_UNSUPERVISED, + ): + self._attr_is_opening = False + self._attr_is_closing = False + self.async_write_ha_state() class CoverTiltMixin(ZWaveBaseEntity, CoverEntity): @@ -297,6 +356,17 @@ def __init__( ), ) + # Multilevel Switch CC v3 and earlier don't report targetValue, + # so we cannot determine when the cover stops moving, + # especially when the device is controlled physically. + # OPENING/CLOSING states must not be used for these devices, + # because they will become stale/incorrect. + if ( + self.info.primary_value.command_class == CommandClass.SWITCH_MULTILEVEL + and self.info.primary_value.cc_version < 4 + ): + self._moving_state_disabled = True + # Entity class attributes self._attr_device_class = CoverDeviceClass.WINDOW if ( @@ -425,15 +495,33 @@ def _tilt_range(self) -> int: async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self._async_set_value(self._up_value, True) + result = await self._async_set_value(self._up_value, True) + # StartLevelChange: SUCCESS means the device started moving in the desired direction + if result is not None and result.status in SET_VALUE_SUCCESS: + self._attr_is_opening = True + self._attr_is_closing = False + self.async_write_ha_state() async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - await self._async_set_value(self._down_value, True) + result = await self._async_set_value(self._down_value, True) + # StartLevelChange: SUCCESS means the device started moving in the desired direction + if result is not None and result.status in SET_VALUE_SUCCESS: + self._attr_is_opening = False + self._attr_is_closing = True + self.async_write_ha_state() async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - await self._async_set_value(self._up_value, False) + result = await self._async_set_value(self._up_value, False) + # When stopping is successful (or unsupervised), we can assume the cover has stopped moving. + if result is not None and result.status in ( + SetValueStatus.SUCCESS, + SetValueStatus.SUCCESS_UNSUPERVISED, + ): + self._attr_is_opening = False + self._attr_is_closing = False + self.async_write_ha_state() class ZwaveMotorizedBarrier(ZWaveBaseEntity, CoverEntity): diff --git a/homeassistant/components/zwave_js/diagnostics.py b/homeassistant/components/zwave_js/diagnostics.py index 1929341a4be9c2..b6364fdda919ca 100644 --- a/homeassistant/components/zwave_js/diagnostics.py +++ b/homeassistant/components/zwave_js/diagnostics.py @@ -28,7 +28,7 @@ ) from .models import ZwaveJSConfigEntry -KEYS_TO_REDACT = {"homeId", "location"} +KEYS_TO_REDACT = {"homeId", "location", "dsk"} VALUES_TO_REDACT = ( ZwaveValueMatcher(property_="userCode", command_class=CommandClass.USER_CODE), diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index e1d1052bb87775..1de49bcc6694be 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -205,7 +205,7 @@ class ZWaveDiscoverySchema: FanValueMapping(speeds=[(1, 33), (34, 67), (68, 99)]), ), ), - # GE/Jasco - In-Wall Smart Fan Controls + # GE/Jasco - In-Wall Smart Fan Controls - 14287 / 55258 / ZW4002 and 14314 / ZW4002 ZWaveDiscoverySchema( platform=Platform.FAN, hint="has_fan_value_mapping", @@ -213,7 +213,6 @@ class ZWaveDiscoverySchema: product_id={ 0x3131, 0x3337, # 14287 / 55258 / ZW4002 - 0x3533, # 58446 / ZWA4013 0x3138, # 14314 / ZW4002 }, product_type={0x4944}, @@ -222,6 +221,18 @@ class ZWaveDiscoverySchema: FanValueMapping(speeds=[(1, 32), (33, 66), (67, 99)]), ), ), + # GE/Jasco - In-Wall Smart Fan Controls - 58446 / ZWA4013 + ZWaveDiscoverySchema( + platform=Platform.FAN, + hint="has_fan_value_mapping", + manufacturer_id={0x0063}, + product_id={0x3533}, + product_type={0x4944}, + primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + data_template=FixedFanValueMappingDataTemplate( + FanValueMapping(speeds=[(1, 25), (26, 50), (51, 75), (76, 99)]), + ), + ), # Leviton ZW4SF fan controllers using switch multilevel CC ZWaveDiscoverySchema( platform=Platform.FAN, diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index 8e47cbbeb1ddbc..710c0523271315 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -267,22 +267,10 @@ def percentage_to_zwave_speed(self, percentage: int) -> int: if percentage == 0: return 0 - # Since the percentage steps are computed with rounding, we have to - # search to find the appropriate speed. - for speed_range in self.fan_value_mapping.speeds: - (_, max_speed) = speed_range - step_percentage = self.zwave_speed_to_percentage(max_speed) - - # zwave_speed_to_percentage will only return None if - # `self.fan_value_mapping.speeds` doesn't contain the - # specified speed. This can't happen here, because - # the input is coming from the same data structure. - assert step_percentage - - if percentage <= step_percentage: - break - - return max_speed + speed_level = math.ceil( + percentage_to_ranged_value((1, self.speed_count), percentage) + ) + return self.fan_value_mapping.speeds[speed_level - 1][1] def zwave_speed_to_percentage(self, zwave_speed: int) -> int | None: """Convert a Zwave speed to a percentage. @@ -293,15 +281,9 @@ def zwave_speed_to_percentage(self, zwave_speed: int) -> int | None: if zwave_speed == 0: return 0 - percentage = 0.0 - for speed_range in self.fan_value_mapping.speeds: - (min_speed, max_speed) = speed_range - percentage += self.percentage_step + for index, (min_speed, max_speed) in enumerate(self.fan_value_mapping.speeds): if min_speed <= zwave_speed <= max_speed: - # This choice of rounding function is to provide consistency with how - # the UI handles steps e.g., for a 3-speed fan, you get steps at 33, - # 67, and 100. - return round(percentage) + return ranged_value_to_percentage((1, self.speed_count), index + 1) # The specified Z-Wave device value doesn't map to a defined speed. return None diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index fbee3bda3ab421..3cb9dea3979add 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -16,6 +16,10 @@ ConfigurationValueType, LogLevel, ) +from zwave_js_server.const.command_class.notification import ( + CC_SPECIFIC_NOTIFICATION_TYPE, + NotificationType, +) from zwave_js_server.model.controller import Controller, ProvisioningEntry from zwave_js_server.model.driver import Driver from zwave_js_server.model.log_config import LogConfig @@ -53,6 +57,8 @@ DOMAIN, LIB_LOGGER, LOGGER, + NOTIFICATION_ACCESS_CONTROL_PROPERTY, + OPENING_STATE_PROPERTY_KEY, ) from .models import ZwaveJSConfigEntry @@ -126,6 +132,37 @@ def get_value_of_zwave_value(value: ZwaveValue | None) -> Any | None: return value.value if value else None +def _get_notification_type(value: ZwaveValue) -> int | None: + """Return the notification type for a value, if available.""" + return value.metadata.cc_specific.get(CC_SPECIFIC_NOTIFICATION_TYPE) + + +def is_opening_state_notification_value(value: ZwaveValue) -> bool: + """Return if the value is the Access Control Opening state notification.""" + if ( + value.command_class != CommandClass.NOTIFICATION + or _get_notification_type(value) != NotificationType.ACCESS_CONTROL + ): + return False + + return ( + value.property_ == NOTIFICATION_ACCESS_CONTROL_PROPERTY + and value.property_key == OPENING_STATE_PROPERTY_KEY + ) + + +def get_opening_state_notification_value(node: ZwaveNode) -> ZwaveValue | None: + """Return the Access Control Opening state value for a node.""" + value_id = get_value_id_str( + node, + CommandClass.NOTIFICATION, + NOTIFICATION_ACCESS_CONTROL_PROPERTY, + None, + OPENING_STATE_PROPERTY_KEY, + ) + return node.values.get(value_id) + + async def async_enable_statistics(driver: Driver) -> None: """Enable statistics on the driver.""" await driver.async_enable_statistics("Home Assistant", HA_VERSION) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index f5a73e1f6be6ba..4b6612c67f3982 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -859,13 +859,22 @@ def __init__( ) # Entity class attributes - # Notification sensors have the following name mapping (variables are property - # keys, name is property) + # Notification sensors use the notification event label as the name + # (property_key_name/metadata.label, falling back to property_name) # https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/notifications.json - self._attr_name = self.generate_name( - alternate_value_name=self.info.primary_value.property_name, - additional_info=[self.info.primary_value.property_key_name], - ) + if info.platform_hint == "notification": + self._attr_name = self.generate_name( + alternate_value_name=( + info.primary_value.property_key_name + or info.primary_value.metadata.label + or info.primary_value.property_name + ) + ) + else: + self._attr_name = self.generate_name( + alternate_value_name=info.primary_value.property_name, + additional_info=[info.primary_value.property_key_name], + ) if self.info.primary_value.metadata.states: self._attr_device_class = SensorDeviceClass.ENUM self._attr_options = list(info.primary_value.metadata.states.values()) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 143c43c422c7fe..dbaefc4f1cf8c3 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -1,13 +1,13 @@ { "config": { "abort": { - "addon_get_discovery_info_failed": "Failed to get Z-Wave app discovery info.", - "addon_info_failed": "Failed to get Z-Wave app info.", - "addon_install_failed": "Failed to install the Z-Wave app.", - "addon_required": "The Z-Wave migration flow requires the integration to be configured using the Z-Wave Supervisor app. If you are using Z-Wave JS UI, please follow our [migration instructions]({zwave_js_ui_migration}).", - "addon_set_config_failed": "Failed to set Z-Wave configuration.", - "addon_start_failed": "Failed to start the Z-Wave app.", - "addon_stop_failed": "Failed to stop the Z-Wave app.", + "addon_get_discovery_info_failed": "Failed to get Z-Wave JS app discovery info.", + "addon_info_failed": "Failed to get Z-Wave JS app info.", + "addon_install_failed": "Failed to install the Z-Wave JS app.", + "addon_required": "The Z-Wave migration flow requires the integration to be configured using the Z-Wave JS app. If you are using Z-Wave JS UI, please follow our [migration instructions]({zwave_js_ui_migration}).", + "addon_set_config_failed": "Failed to set Z-Wave JS app configuration.", + "addon_start_failed": "Failed to start the Z-Wave JS app.", + "addon_stop_failed": "Failed to stop the Z-Wave JS app.", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "backup_failed": "Failed to back up network.", @@ -17,15 +17,15 @@ "discovery_requires_supervisor": "Discovery requires the Home Assistant Supervisor.", "migration_low_sdk_version": "The SDK version of the old adapter is lower than {ok_sdk_version}. This means it's not possible to migrate the non-volatile memory (NVM) of the old adapter to another adapter.\n\nCheck the documentation on the manufacturer support pages of the old adapter, if it's possible to upgrade the firmware of the old adapter to a version that is built with SDK version {ok_sdk_version} or higher.", "migration_successful": "Migration successful.", - "not_hassio": "ESPHome discovery requires Home Assistant to configure the Z-Wave app.", + "not_hassio": "ESPHome discovery requires Home Assistant to configure the Z-Wave JS app.", "not_zwave_device": "Discovered device is not a Z-Wave device.", - "not_zwave_js_addon": "Discovered app is not the official Z-Wave app.", + "not_zwave_js_addon": "Discovered app is not the official Z-Wave JS app.", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "reset_failed": "Failed to reset adapter.", "usb_ports_failed": "Failed to get USB devices." }, "error": { - "addon_start_failed": "Failed to start the Z-Wave app. Check the configuration.", + "addon_start_failed": "Failed to start the Z-Wave JS app. Check the configuration.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_ws_url": "Invalid websocket URL", "unknown": "[%key:common::config_flow::error::unknown%]" @@ -65,7 +65,7 @@ "usb_path": "[%key:common::config_flow::data::usb_path%]" }, "description": "Select your Z-Wave adapter", - "title": "Enter the Z-Wave app configuration" + "title": "Enter the Z-Wave JS app configuration" }, "configure_security_keys": { "data": { @@ -84,7 +84,7 @@ "title": "Migrate to a new adapter" }, "hassio_confirm": { - "description": "Do you want to set up the Z-Wave integration with the Z-Wave app?" + "description": "Do you want to set up the Z-Wave integration with the Z-Wave JS app?" }, "install_addon": { "title": "Installing app" @@ -127,9 +127,9 @@ }, "on_supervisor": { "data": { - "use_addon": "Use the Z-Wave Supervisor app" + "use_addon": "Use the Z-Wave JS app" }, - "description": "Do you want to use the Z-Wave Supervisor app?", + "description": "Do you want to use the Z-Wave JS app?", "title": "Select connection method" }, "on_supervisor_reconfigure": { @@ -140,16 +140,16 @@ "title": "[%key:component::zwave_js::config::step::on_supervisor::title%]" }, "reconfigure": { - "description": "Are you migrating to a new adapter or re-configuring the current adapter?", + "description": "Are you migrating to a new adapter or reconfiguring the current adapter?", "menu_option_descriptions": { "intent_migrate": "This will move your Z-Wave network to a new adapter.", "intent_reconfigure": "This will let you change the adapter configuration." }, "menu_options": { "intent_migrate": "Migrate to a new adapter", - "intent_reconfigure": "Re-configure the current adapter" + "intent_reconfigure": "Reconfigure the current adapter" }, - "title": "Migrate or re-configure" + "title": "Migrate or reconfigure" }, "restore_failed": { "description": "Your Z-Wave network could not be restored to the new adapter. This means that your Z-Wave devices are not connected to Home Assistant.\n\nThe backup is saved to ”{file_path}”\n\n'<'a href=\"{file_url}\" download=\"{file_name}\"'>'Download backup file'<'/a'>'", diff --git a/homeassistant/components/zwave_me/manifest.json b/homeassistant/components/zwave_me/manifest.json index 1549bbce6b536a..0f12a537b42464 100644 --- a/homeassistant/components/zwave_me/manifest.json +++ b/homeassistant/components/zwave_me/manifest.json @@ -5,6 +5,7 @@ "codeowners": ["@lawfulchaos", "@Z-Wave-Me", "@PoltoS"], "config_flow": true, "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"], "zeroconf": [ diff --git a/homeassistant/components/zwave_me/strings.json b/homeassistant/components/zwave_me/strings.json index 3b7e1033c09ba9..28bb59419583db 100644 --- a/homeassistant/components/zwave_me/strings.json +++ b/homeassistant/components/zwave_me/strings.json @@ -13,7 +13,7 @@ "token": "[%key:common::config_flow::data::api_token%]", "url": "[%key:common::config_flow::data::url%]" }, - "description": "Input IP address with port and access token of Z-Way server. To get the token go to the Z-Way user interface Smart Home UI > Menu > Settings > Users > Administrator > API token.\n\nExample of connecting to Z-Way running as an add-on:\nURL: {add_on_url}\nToken: {local_token}\n\nExample of connecting to Z-Way in the local network:\nURL: {local_url}\nToken: {local_token}\n\nExample of connecting to Z-Way via remote access find.z-wave.me:\nURL: {find_url}\nToken: {find_token}\n\nExample of connecting to Z-Way with a static public IP address:\nURL: {remote_url}\nToken: {local_token}\n\nWhen connecting via find.z-wave.me you need to use a token with a global scope (log in to Z-Way via find.z-wave.me for this)." + "description": "Input IP address with port and access token of Z-Way server. To get the token go to the Z-Way user interface Smart Home UI > Menu > Settings > Users > Administrator > API token.\n\nExample of connecting to Z-Way running as an app:\nURL: {add_on_url}\nToken: {local_token}\n\nExample of connecting to Z-Way in the local network:\nURL: {local_url}\nToken: {local_token}\n\nExample of connecting to Z-Way via remote access find.z-wave.me:\nURL: {find_url}\nToken: {find_token}\n\nExample of connecting to Z-Way with a static public IP address:\nURL: {remote_url}\nToken: {local_token}\n\nWhen connecting via find.z-wave.me you need to use a token with a global scope (log in to Z-Way via find.z-wave.me for this)." } } } diff --git a/homeassistant/config.py b/homeassistant/config.py index ce95a94fdfd1e1..7bd8c4e3c8a8d0 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -15,7 +15,7 @@ from pathlib import Path import shutil from types import ModuleType -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal, overload from awesomeversion import AwesomeVersion import voluptuous as vol @@ -851,6 +851,36 @@ def _get_log_message_and_stack_print_pref( return (log_message, show_stack_trace, placeholders) +# The complicated overloads are due to a limitation in mypy, details in +# https://github.com/python/mypy/issues/7333 +@overload +async def async_process_component_and_handle_errors( + hass: HomeAssistant, + config: ConfigType, + integration: Integration, +) -> ConfigType | None: ... + + +@overload +async def async_process_component_and_handle_errors( + hass: HomeAssistant, + config: ConfigType, + integration: Integration, + *, + raise_on_failure: Literal[True], +) -> ConfigType: ... + + +@overload +async def async_process_component_and_handle_errors( + hass: HomeAssistant, + config: ConfigType, + integration: Integration, + *, + raise_on_failure: bool, +) -> ConfigType | None: ... + + async def async_process_component_and_handle_errors( hass: HomeAssistant, config: ConfigType, diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index dc69d6695826c6..ab4c2d7d7b334c 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -69,7 +69,13 @@ ) from .helpers.frame import ReportBehavior, report_usage from .helpers.json import json_bytes, json_bytes_sorted, json_fragment -from .helpers.typing import UNDEFINED, ConfigType, DiscoveryInfoType, UndefinedType +from .helpers.typing import ( + UNDEFINED, + ConfigType, + DiscoveryInfoType, + NoEventData, + UndefinedType, +) from .loader import async_suggest_report_issue from .setup import ( SetupPhases, @@ -792,6 +798,7 @@ async def __async_setup_with_context( self.domain, auth_message, ) + _LOGGER.debug("Full exception", exc_info=True) self.async_start_reauth(hass) except ConfigEntryNotReady as exc: message = str(exc) @@ -809,13 +816,14 @@ async def __async_setup_with_context( ) self._tries += 1 ready_message = f"ready yet: {message}" if message else "ready yet" - _LOGGER.debug( + _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) if hass.state is CoreState.running: self._async_cancel_retry_setup = async_call_later( @@ -2237,6 +2245,54 @@ async def async_initialize(self) -> None: self._entries = entries self.async_update_issues() + if not self.hass.config.recovery_mode and not self.hass.config.safe_mode: + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STARTED, self._async_scan_orphan_ignored_entries + ) + + async def _async_scan_orphan_ignored_entries( + self, event: Event[NoEventData] + ) -> None: + """Scan for ignored entries that can be removed. + + Orphaned ignored entries are entries that are in ignored state + for integrations that are no longer available. + """ + remove_candidates = [ + entry + for entry in self.async_entries( + include_ignore=True, + include_disabled=False, + ) + if entry.source == SOURCE_IGNORE + ] + + if not remove_candidates: + return + + for entry in remove_candidates: + try: + await loader.async_get_integration(self.hass, entry.domain) + except loader.IntegrationNotFound: + _LOGGER.info( + "Integration for ignored config entry %s not found. Creating repair issue", + entry, + ) + ir.async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + issue_id=f"orphaned_ignored_entry.{entry.entry_id}", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="orphaned_ignored_config_entry", + translation_placeholders={"domain": entry.domain}, + data={ + "domain": entry.domain, + "entry_id": entry.entry_id, + }, + ) + async def async_setup(self, entry_id: str, _lock: bool = True) -> bool: """Set up a config entry. diff --git a/homeassistant/const.py b/homeassistant/const.py index eda30234072d59..6c0a918eb1ef97 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -16,7 +16,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 -MINOR_VERSION: Final = 3 +MINOR_VERSION: Final = 4 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" @@ -332,6 +332,9 @@ # Contains one string or a list of strings, each being an entity id ATTR_ENTITY_ID: Final = "entity_id" +# Contains a list of entity ids that are members of a group +ATTR_GROUP_ENTITIES: Final = "group_entities" + # Contains one string, the config entry ID ATTR_CONFIG_ENTRY_ID: Final = "config_entry_id" diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 23416480dd754d..8b1c9c49afef91 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -6,6 +6,9 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any +from aiohttp import ClientResponse, ClientResponseError, RequestInfo +from multidict import MultiMapping + from .util.event_type import EventType if TYPE_CHECKING: @@ -218,6 +221,63 @@ class ConfigEntryAuthFailed(IntegrationError): """Error to indicate that config entry could not authenticate.""" +class OAuth2TokenRequestError(ClientResponseError, HomeAssistantError): + """Error to indicate that the OAuth 2.0 flow could not refresh token.""" + + def __init__( + self, + *, + request_info: RequestInfo, + history: tuple[ClientResponse, ...] = (), + status: int = 0, + message: str = "OAuth 2.0 token refresh failed", + headers: MultiMapping[str] | None = None, + domain: str, + ) -> None: + """Initialize OAuth2RefreshTokenFailed.""" + ClientResponseError.__init__( + self, + request_info=request_info, + history=history, + status=status, + message=message, + headers=headers, + ) + HomeAssistantError.__init__(self) + self.domain = domain + self.translation_domain = "homeassistant" + self.translation_key = "oauth2_helper_refresh_failed" + self.translation_placeholders = {"domain": domain} + self.generate_message = True + + +class OAuth2TokenRequestTransientError(OAuth2TokenRequestError): + """Recoverable error to indicate flow could not refresh token.""" + + def __init__(self, *, domain: str, **kwargs: Any) -> None: + """Initialize OAuth2RefreshTokenTransientError.""" + super().__init__(domain=domain, **kwargs) + self.translation_domain = "homeassistant" + self.translation_key = "oauth2_helper_refresh_transient" + self.translation_placeholders = {"domain": domain} + self.generate_message = True + + +class OAuth2TokenRequestReauthError(OAuth2TokenRequestError): + """Non recoverable error to indicate the flow could not refresh token. + + Re-authentication is required. + """ + + def __init__(self, *, domain: str, **kwargs: Any) -> None: + """Initialize OAuth2RefreshTokenReauthError.""" + super().__init__(domain=domain, **kwargs) + self.translation_domain = "homeassistant" + self.translation_key = "oauth2_helper_reauth_required" + self.translation_placeholders = {"domain": domain} + self.generate_message = True + + class InvalidStateError(HomeAssistantError): """When an invalid state is encountered.""" @@ -321,3 +381,20 @@ def __init__(self, failed_dependencies: list[str]) -> None: f"Could not setup dependencies: {', '.join(failed_dependencies)}", ) self.failed_dependencies = failed_dependencies + + +class UnsupportedStorageVersionError(HomeAssistantError): + """Raised when a storage file has a newer major version than expected.""" + + def __init__( + self, storage_key: str, found_version: int, max_supported_version: int + ) -> None: + """Initialize error.""" + super().__init__( + f"Storage file {storage_key} has version {found_version}" + f" which is newer than the max supported version {max_supported_version};" + " upgrade Home Assistant or restore from a backup", + ) + self.storage_key = storage_key + self.found_version = found_version + self.max_supported_version = max_supported_version 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 51709a3b54812f..8abd999eedf908 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -85,6 +85,11 @@ "domain": "bthome", "service_data_uuid": "0000fcd2-0000-1000-8000-00805f9b34fb", }, + { + "connectable": True, + "domain": "casper_glow", + "local_name": "Jar*", + }, { "domain": "dormakaba_dkey", "service_uuid": "e7a60000-6639-429f-94fd-86de8ea26897", @@ -212,6 +217,11 @@ "domain": "govee_ble", "local_name": "GVH5110*", }, + { + "connectable": False, + "domain": "govee_ble", + "local_name": "GV5140*", + }, { "connectable": False, "domain": "govee_ble", @@ -615,6 +625,11 @@ "domain": "motionblinds_ble", "local_name": "MOTION_*", }, + { + "connectable": True, + "domain": "opendisplay", + "manufacturer_id": 9286, + }, { "domain": "oralb", "manufacturer_id": 220, diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 463fd28ec96c9a..f925a211d4df99 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -82,6 +82,7 @@ "aurora_abb_powerone", "aussie_broadband", "autarco", + "autoskope", "awair", "aws_s3", "axis", @@ -100,7 +101,6 @@ "bluemaestro", "bluesound", "bluetooth", - "bmw_connected_drive", "bond", "bosch_alarm", "bosch_shc", @@ -117,10 +117,12 @@ "caldav", "cambridge_audio", "canary", + "casper_glow", "cast", "ccm15", "cert_expiry", "chacon_dio", + "chess_com", "cloudflare", "cloudflare_r2", "co2signal", @@ -157,11 +159,11 @@ "downloader", "dremel_3d_printer", "drop_connect", + "dropbox", "droplet", "dsmr", "dsmr_reader", "duckdns", - "duke_energy", "dunehd", "duotecno", "dwd_weather_warnings", @@ -230,6 +232,7 @@ "foscam", "freebox", "freedompro", + "freshr", "fressnapf_tracker", "fritz", "fritzbox", @@ -314,6 +317,7 @@ "hvv_departures", "hydrawise", "hyperion", + "hypontech", "ialarm", "iaqualink", "ibeacon", @@ -328,7 +332,9 @@ "immich", "improv_ble", "incomfort", + "indevolt", "inels", + "influxdb", "inkbird", "insteon", "intelliclima", @@ -383,6 +389,7 @@ "lg_soundbar", "lg_thinq", "libre_hardware_monitor", + "lichess", "lidarr", "liebherr", "lifx", @@ -395,6 +402,7 @@ "local_ip", "local_todo", "locative", + "lojack", "london_underground", "lookin", "loqed", @@ -444,9 +452,11 @@ "motionmount", "mpd", "mqtt", + "mta", "mullvad", "music_assistant", "mutesync", + "myneomitis", "mysensors", "mystrom", "myuplink", @@ -456,6 +466,7 @@ "nasweb", "neato", "nederlandse_spoorwegen", + "ness_alarm", "nest", "netatmo", "netgear", @@ -498,6 +509,7 @@ "open_meteo", "open_router", "openai_conversation", + "opendisplay", "openevse", "openexchangerates", "opengarage", @@ -509,6 +521,7 @@ "openweathermap", "opower", "oralb", + "orvibo", "osoenergy", "otbr", "otp", @@ -539,6 +552,7 @@ "poolsense", "portainer", "powerfox", + "powerfox_local", "powerwall", "prana", "private_ble_device", @@ -687,6 +701,7 @@ "synology_dsm", "system_bridge", "systemmonitor", + "systemnexa2", "tado", "tailscale", "tailwind", @@ -698,6 +713,7 @@ "tedee", "telegram_bot", "tellduslive", + "teltonika", "tesla_fleet", "tesla_wall_connector", "teslemetry", @@ -728,8 +744,10 @@ "trafikverket_ferry", "trafikverket_train", "trafikverket_weatherstation", + "trane", "transmission", "triggercmd", + "trmnl", "tuya", "twentemilieu", "twilio", @@ -738,6 +756,7 @@ "uhoo", "ukraine_alarm", "unifi", + "unifi_access", "unifiprotect", "upb", "upcloud", @@ -786,6 +805,7 @@ "whirlpool", "whois", "wiffi", + "wiim", "wilight", "withings", "wiz", @@ -815,6 +835,7 @@ "zeversolar", "zha", "zimi", + "zinvolt", "zodiac", "zwave_js", "zwave_me", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index e650435a2e0e86..37c6f63a6575d6 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -17,6 +17,10 @@ "domain": "airobot", "hostname": "airobot-thermostat-*", }, + { + "domain": "airos", + "registered_devices": True, + }, { "domain": "airthings", "hostname": "airthings-view", @@ -813,6 +817,15 @@ "hostname": "hub*", "macaddress": "286D97*", }, + { + "domain": "smartthings", + "hostname": "smarthub", + "macaddress": "683A48*", + }, + { + "domain": "smartthings", + "hostname": "samsung-*", + }, { "domain": "smlight", "registered_devices": True, @@ -853,6 +866,14 @@ "domain": "tailwind", "registered_devices": True, }, + { + "domain": "teltonika", + "macaddress": "209727*", + }, + { + "domain": "teltonika", + "macaddress": "001E42*", + }, { "domain": "tesla_wall_connector", "hostname": "teslawallconnector_*", diff --git a/homeassistant/generated/entity_platforms.py b/homeassistant/generated/entity_platforms.py index 7010ffc9be73c1..718c3745be890b 100644 --- a/homeassistant/generated/entity_platforms.py +++ b/homeassistant/generated/entity_platforms.py @@ -29,6 +29,7 @@ class EntityPlatforms(StrEnum): HUMIDIFIER = "humidifier" IMAGE = "image" IMAGE_PROCESSING = "image_processing" + INFRARED = "infrared" LAWN_MOWER = "lawn_mower" LIGHT = "light" LOCK = "lock" diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a18fbe6822c9e5..81f0628f7d29ad 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -257,7 +257,7 @@ "aws_s3": { "integration_type": "service", "config_flow": true, - "iot_class": "cloud_push", + "iot_class": "cloud_polling", "name": "AWS S3" }, "fire_tv": { @@ -303,6 +303,23 @@ "config_flow": false, "iot_class": "local_polling" }, + "american_standard": { + "name": "American Standard", + "integrations": { + "nexia": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Nexia/American Standard/Trane" + }, + "trane": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "Trane Local" + } + } + }, "amp_motorization": { "name": "AMP Motorization", "integration_type": "virtual", @@ -364,7 +381,7 @@ "iot_class": "local_push" }, "anthropic": { - "name": "Anthropic Conversation", + "name": "Anthropic", "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" @@ -441,7 +458,7 @@ "name": "Apple iTunes" }, "weatherkit": { - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling", "name": "Apple WeatherKit" @@ -600,7 +617,7 @@ "name": "August" }, "yalexs_ble": { - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push", "name": "Yale Access Bluetooth" @@ -630,6 +647,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "autoskope": { + "name": "Autoskope", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "avion": { "name": "Avi-on", "integration_type": "hub", @@ -800,12 +823,6 @@ "config_flow": false, "iot_class": "local_push" }, - "bmw_connected_drive": { - "name": "BMW Connected Drive", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" - }, "bond": { "name": "Bond", "integration_type": "hub", @@ -888,7 +905,7 @@ "iot_class": "local_polling" }, "bsblan": { - "name": "BSB-Lan", + "name": "BSB-LAN", "integration_type": "device", "config_flow": true, "iot_class": "local_polling" @@ -956,6 +973,12 @@ "iot_class": "cloud_polling", "single_config_entry": true }, + "casper_glow": { + "name": "Casper Glow", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "ccm15": { "name": "Midea ccm15 AC Controller", "integration_type": "hub", @@ -979,6 +1002,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "chess_com": { + "name": "Chess.com", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "cisco": { "name": "Cisco", "integrations": { @@ -1450,6 +1479,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", @@ -1480,12 +1515,6 @@ "config_flow": true, "iot_class": "cloud_polling" }, - "duke_energy": { - "name": "Duke Energy", - "integration_type": "service", - "config_flow": true, - "iot_class": "cloud_polling" - }, "dunehd": { "name": "Dune HD", "integration_type": "device", @@ -1767,7 +1796,7 @@ }, "enocean": { "name": "EnOcean", - "integration_type": "device", + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "single_config_entry": true @@ -2197,6 +2226,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "freshr": { + "name": "Fresh-r", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "fressnapf_tracker": { "name": "Fressnapf Tracker", "integration_type": "hub", @@ -2990,6 +3025,12 @@ "config_flow": true, "iot_class": "local_push" }, + "hypontech": { + "name": "Hypontech Cloud", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "ialarm": { "name": "Antifurto365 iAlarm", "integration_type": "device", @@ -3110,6 +3151,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "indevolt": { + "name": "Indevolt", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "indianamichiganpower": { "name": "Indiana Michigan Power", "integration_type": "virtual", @@ -3125,8 +3172,9 @@ "influxdb": { "name": "InfluxDB", "integration_type": "hub", - "config_flow": false, - "iot_class": "local_push" + "config_flow": true, + "iot_class": "local_push", + "single_config_entry": true }, "inkbird": { "name": "INKBIRD", @@ -3641,6 +3689,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "lichess": { + "name": "Lichess", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "lidarr": { "name": "Lidarr", "integration_type": "service", @@ -3724,7 +3778,7 @@ "single_config_entry": true }, "litterrobot": { - "name": "Litter-Robot", + "name": "Whisker", "integration_type": "hub", "config_flow": true, "iot_class": "cloud_push" @@ -3792,6 +3846,12 @@ } } }, + "lojack": { + "name": "LoJack", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "london_air": { "name": "London Air", "integration_type": "hub", @@ -4177,11 +4237,6 @@ "config_flow": true, "iot_class": "local_polling" }, - "mini_connected": { - "name": "MINI Connected", - "integration_type": "virtual", - "supported_by": "bmw_connected_drive" - }, "minio": { "name": "Minio", "integration_type": "hub", @@ -4348,6 +4403,12 @@ } } }, + "mta": { + "name": "MTA New York City Transit", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "mullvad": { "name": "Mullvad VPN", "integration_type": "service", @@ -4379,6 +4440,12 @@ "config_flow": false, "iot_class": "local_push" }, + "myneomitis": { + "name": "MyNeomitis", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "mysensors": { "name": "MySensors", "integration_type": "hub", @@ -4463,7 +4530,7 @@ "ness_alarm": { "name": "Ness Alarm", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_push" }, "netatmo": { @@ -4507,12 +4574,6 @@ "config_flow": false, "iot_class": "cloud_polling" }, - "nexia": { - "name": "Nexia/American Standard/Trane", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" - }, "nexity": { "name": "Nexity Eug\u00e9nie", "integration_type": "virtual", @@ -4839,6 +4900,12 @@ "config_flow": false, "iot_class": "cloud_push" }, + "opendisplay": { + "name": "OpenDisplay", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "openerz": { "name": "Open ERZ", "integration_type": "hub", @@ -4965,8 +5032,8 @@ }, "orvibo": { "name": "Orvibo", - "integration_type": "hub", - "config_flow": false, + "integration_type": "device", + "config_flow": true, "iot_class": "local_push" }, "osoenergy": { @@ -5006,7 +5073,7 @@ "iot_class": "local_polling" }, "overseerr": { - "name": "Overseerr", + "name": "Seerr", "integration_type": "service", "config_flow": true, "iot_class": "local_push" @@ -5261,9 +5328,20 @@ }, "powerfox": { "name": "Powerfox", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" + "integrations": { + "powerfox": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Powerfox Cloud" + }, + "powerfox_local": { + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling", + "name": "Powerfox Local" + } + } }, "prana": { "name": "Prana", @@ -5907,7 +5985,7 @@ "name": "Samsung Smart TV" }, "syncthru": { - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling", "name": "Samsung SyncThru Printer" @@ -6008,7 +6086,7 @@ }, "sensorpro": { "name": "SensorPro", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, @@ -6016,7 +6094,7 @@ "name": "SensorPush", "integrations": { "sensorpush": { - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push", "name": "SensorPush" @@ -6163,7 +6241,7 @@ }, "simplepush": { "name": "Simplepush", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -6261,7 +6339,7 @@ }, "slimproto": { "name": "SlimProto (Squeezebox players)", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, @@ -6329,7 +6407,7 @@ }, "smhi": { "name": "SMHI", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -6370,7 +6448,7 @@ }, "snooz": { "name": "Snooz", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, @@ -6399,7 +6477,7 @@ }, "solax": { "name": "SolaX Power", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -6422,7 +6500,7 @@ }, "sonarr": { "name": "Sonarr", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "local_polling" }, @@ -6454,7 +6532,7 @@ "name": "Sony Projector" }, "songpal": { - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push", "name": "Sony Songpal" @@ -6469,7 +6547,7 @@ }, "soundtouch": { "name": "Bose SoundTouch", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -6493,7 +6571,7 @@ }, "splunk": { "name": "Splunk", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "local_push", "single_config_entry": true @@ -6512,7 +6590,7 @@ }, "srp_energy": { "name": "SRP Energy", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -6530,7 +6608,7 @@ }, "starlink": { "name": "Starlink", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -6554,13 +6632,13 @@ }, "steamist": { "name": "Steamist", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, "stiebel_eltron": { "name": "STIEBEL ELTRON", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -6572,7 +6650,7 @@ }, "streamlabswater": { "name": "StreamLabs", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -6584,7 +6662,7 @@ }, "suez_water": { "name": "Suez Water", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -6637,7 +6715,7 @@ }, "swiss_public_transport": { "name": "Swiss public transport", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -6688,7 +6766,7 @@ }, "syncthing": { "name": "Syncthing", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "local_polling" }, @@ -6734,6 +6812,12 @@ "iot_class": "local_push", "single_config_entry": true }, + "systemnexa2": { + "name": "System Nexa 2", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "tado": { "name": "Tado", "integration_type": "hub", @@ -6754,7 +6838,7 @@ }, "tami4": { "name": "Tami4 Edge / Edge+", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "cloud_polling" }, @@ -6822,7 +6906,7 @@ "name": "Telegram" }, "telegram_bot": { - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_push", "name": "Telegram bot" @@ -6852,6 +6936,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "teltonika": { + "name": "Teltonika", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "temper": { "name": "TEMPer", "integration_type": "hub", @@ -6868,7 +6958,7 @@ "name": "Tesla Powerwall" }, "tesla_wall_connector": { - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling", "name": "Tesla Wall Connector" @@ -6893,12 +6983,6 @@ "config_flow": true, "iot_class": "cloud_polling" }, - "tfiac": { - "name": "Tfiac", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_polling" - }, "thermador": { "name": "Thermador", "integration_type": "virtual", @@ -6906,7 +6990,7 @@ }, "thermobeacon": { "name": "ThermoBeacon", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, @@ -6917,7 +7001,7 @@ }, "thermopro": { "name": "ThermoPro", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, @@ -6987,7 +7071,7 @@ "name": "Tilt", "integrations": { "tilt_ble": { - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push", "name": "Tilt Hydrometer BLE" @@ -7013,19 +7097,19 @@ }, "todoist": { "name": "Todoist", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, "togrill": { "name": "ToGrill", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, "tolo": { "name": "TOLO Sauna", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -7043,7 +7127,7 @@ }, "toon": { "name": "Toon", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "cloud_push" }, @@ -7118,31 +7202,48 @@ "name": "Trafikverket", "integrations": { "trafikverket_camera": { - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling", "name": "Trafikverket Camera" }, "trafikverket_ferry": { - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling", "name": "Trafikverket Ferry" }, "trafikverket_train": { - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling", "name": "Trafikverket Train" }, "trafikverket_weatherstation": { - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling", "name": "Trafikverket Weather Station" } } }, + "trane": { + "name": "Trane", + "integrations": { + "nexia": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Nexia/American Standard/Trane" + }, + "trane": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "Trane Local" + } + } + }, "transmission": { "name": "Transmission", "integration_type": "service", @@ -7167,6 +7268,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "trmnl": { + "name": "TRMNL", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "tuya": { "name": "Tuya", "integration_type": "hub", @@ -7183,7 +7290,7 @@ "name": "Twilio", "integrations": { "twilio": { - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_push", "name": "Twilio" @@ -7204,13 +7311,13 @@ }, "twinkly": { "name": "Twinkly", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, "twitch": { "name": "Twitch", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -7248,6 +7355,12 @@ "iot_class": "local_push", "name": "UniFi Network" }, + "unifi_access": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "UniFi Access" + }, "unifi_direct": { "integration_type": "hub", "config_flow": false, @@ -7268,6 +7381,12 @@ } } }, + "ubisys": { + "name": "Ubisys", + "iot_standards": [ + "zigbee" + ] + }, "ubiwizz": { "name": "Ubiwizz", "integration_type": "virtual", @@ -7292,7 +7411,7 @@ }, "ukraine_alarm": { "name": "Ukraine Alarm", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -7316,7 +7435,7 @@ }, "upcloud": { "name": "UpCloud", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -7345,7 +7464,7 @@ }, "uptimerobot": { "name": "UptimeRobot", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -7363,7 +7482,7 @@ }, "v2c": { "name": "V2C", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -7374,7 +7493,7 @@ }, "vallox": { "name": "Vallox", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -7403,7 +7522,7 @@ }, "venstar": { "name": "Venstar", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -7472,13 +7591,13 @@ }, "vilfo": { "name": "Vilfo Router", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, "vivotek": { "name": "VIVOTEK", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -7498,7 +7617,7 @@ "name": "VLC media player" }, "vlc_telnet": { - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "local_polling", "name": "VLC media player via Telnet" @@ -7531,7 +7650,7 @@ }, "volumio": { "name": "Volumio", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -7561,13 +7680,13 @@ }, "wallbox": { "name": "Wallbox", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "cloud_polling" }, "waqi": { "name": "World Air Quality Index (WAQI)", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -7579,7 +7698,7 @@ }, "watergate": { "name": "Watergate", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, @@ -7602,7 +7721,7 @@ "iot_class": "cloud_polling" }, "waze_travel_time": { - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -7670,6 +7789,12 @@ "config_flow": true, "iot_class": "local_push" }, + "wiim": { + "name": "WiiM", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "wilight": { "name": "WiLight", "integration_type": "hub", @@ -7690,7 +7815,7 @@ }, "wiz": { "name": "WiZ", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, @@ -7708,7 +7833,7 @@ }, "wolflink": { "name": "Wolf SmartSet Service", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "cloud_polling" }, @@ -7719,7 +7844,7 @@ }, "worldclock": { "name": "Worldclock", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "local_push" }, @@ -7737,7 +7862,7 @@ }, "ws66i": { "name": "Soundavo WS66i 6-Zone Amplifier", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -7822,7 +7947,7 @@ "name": "Yale Home" }, "yalexs_ble": { - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push", "name": "Yale Access Bluetooth" @@ -7862,7 +7987,7 @@ "name": "Yamaha Network Receivers" }, "yamaha_musiccast": { - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push", "name": "MusicCast" @@ -7888,7 +8013,7 @@ }, "yardian": { "name": "Yardian", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -7896,7 +8021,7 @@ "name": "Yeelight", "integrations": { "yeelight": { - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push", "name": "Yeelight" @@ -7923,7 +8048,7 @@ }, "youless": { "name": "YouLess", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -7935,7 +8060,7 @@ }, "zamg": { "name": "GeoSphere Austria", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -7992,6 +8117,12 @@ "config_flow": true, "iot_class": "local_push" }, + "zinvolt": { + "name": "Zinvolt", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "zodiac": { "integration_type": "hub", "config_flow": true, diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index f52eadfad2a56b..d1974f23d6e5b8 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -4,6 +4,13 @@ """ USB = [ + { + "description": "*usb 300*", + "domain": "enocean", + "manufacturer": "*enocean*", + "pid": "6001", + "vid": "0403", + }, { "description": "*zbt-2*", "domain": "homeassistant_connect_zbt2", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index b3b89464d31487..8cd43f195af67b 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -565,6 +565,12 @@ }, ], "_http._tcp.local.": [ + { + "domain": "airq", + "properties": { + "device": "air-q", + }, + }, { "domain": "awair", "name": "awair*", @@ -627,6 +633,10 @@ "domain": "powerfox", "name": "powerfox*", }, + { + "domain": "powerfox_local", + "name": "powerfox*", + }, { "domain": "pure_energie", "name": "smartbridge*", @@ -701,6 +711,9 @@ { "domain": "linkplay", }, + { + "domain": "wiim", + }, ], "_lookin._tcp.local.": [ { @@ -957,6 +970,11 @@ "domain": "system_bridge", }, ], + "_systemnexa2._tcp.local.": [ + { + "domain": "systemnexa2", + }, + ], "_technove-stations._tcp.local.": [ { "domain": "technove", diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index cf40441bf5f342..0939c31eadca8b 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -87,6 +87,12 @@ async def _ssrf_redirect_middleware( # Relative redirects stay on the same host - always safe return resp + # Only schemes that aiohttp can open a network connection for need + # SSRF protection. Custom app URI schemes (e.g. weconnect://) are inert + # from a networking perspective and must not be blocked. + if connector and redirect_url.scheme not in connector.allowed_protocol_schema_set: + return resp + host = redirect_url.host if await _async_is_blocked_host(host, connector): resp.close() diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 6fb98c63e66b63..7732b2001eda7f 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -447,7 +447,7 @@ def async_reorder(self, area_ids: list[str]) -> None: EventAreaRegistryUpdatedData(action="reorder", area_id=None), ) - async def async_load(self) -> None: + async def _async_load(self) -> None: """Load the area registry.""" self._async_setup_cleanup() @@ -549,10 +549,10 @@ def async_get(hass: HomeAssistant) -> AreaRegistry: return AreaRegistry(hass) -async def async_load(hass: HomeAssistant) -> None: +async def async_load(hass: HomeAssistant, *, load_empty: bool = False) -> None: """Load area registry.""" assert DATA_REGISTRY not in hass.data - await async_get(hass).async_load() + await async_get(hass).async_load(load_empty=load_empty) @callback diff --git a/homeassistant/helpers/automation.py b/homeassistant/helpers/automation.py index 927d41a98bc2d1..f928331b99ab6e 100644 --- a/homeassistant/helpers/automation.py +++ b/homeassistant/helpers/automation.py @@ -1,14 +1,68 @@ """Helpers for automation.""" +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from enum import Enum from typing import Any import voluptuous as vol from homeassistant.const import CONF_OPTIONS +from homeassistant.core import HomeAssistant, split_entity_id +from .entity import get_device_class_or_undefined from .typing import ConfigType +class AnyDeviceClassType(Enum): + """Singleton type for matching any device class.""" + + _singleton = 0 + + +ANY_DEVICE_CLASS = AnyDeviceClassType._singleton # noqa: SLF001 + + +@dataclass(frozen=True, slots=True) +class DomainSpec: + """Describes how to match and extract a value from an entity. + + Used by triggers and conditions. + """ + + device_class: str | None | AnyDeviceClassType = ANY_DEVICE_CLASS + value_source: str | None = None + """Attribute name to extract the value from, or None for state.state.""" + + +@dataclass(frozen=True, slots=True) +class NumericalDomainSpec(DomainSpec): + """DomainSpec with an optional value converter for numerical triggers.""" + + value_converter: Callable[[Any], float] | None = None + """Optional converter for numerical values (e.g. uint8 → percentage).""" + + +def filter_by_domain_specs( + hass: HomeAssistant, + domain_specs: Mapping[str, DomainSpec], + entities: set[str], +) -> set[str]: + """Filter entities matching any of the domain specs.""" + result: set[str] = set() + for entity_id in entities: + if not (domain_spec := domain_specs.get(split_entity_id(entity_id)[0])): + continue + if ( + domain_spec.device_class is not ANY_DEVICE_CLASS + and get_device_class_or_undefined(hass, entity_id) + != domain_spec.device_class + ): + continue + result.add(entity_id) + return result + + def get_absolute_description_key(domain: str, key: str) -> str: """Return the absolute description key.""" if not key.startswith("_"): diff --git a/homeassistant/helpers/category_registry.py b/homeassistant/helpers/category_registry.py index 41fa82084b33fa..44481b0f03039f 100644 --- a/homeassistant/helpers/category_registry.py +++ b/homeassistant/helpers/category_registry.py @@ -77,7 +77,7 @@ async def _async_migrate_func( ) -> CategoryRegistryStoreData: """Migrate to the new version.""" if old_major_version > STORAGE_VERSION_MAJOR: - raise ValueError("Can't migrate to future version") + raise NotImplementedError if old_major_version == 1: if old_minor_version < 2: @@ -204,7 +204,7 @@ def async_update( return new - async def async_load(self) -> None: + async def _async_load(self) -> None: """Load the category registry.""" data = await self._store.async_load() category_entries: dict[str, dict[str, CategoryEntry]] = {} @@ -265,7 +265,7 @@ def async_get(hass: HomeAssistant) -> CategoryRegistry: return CategoryRegistry(hass) -async def async_load(hass: HomeAssistant) -> None: +async def async_load(hass: HomeAssistant, *, load_empty: bool = False) -> None: """Load category registry.""" assert DATA_REGISTRY not in hass.data - await async_get(hass).async_load() + await async_get(hass).async_load(load_empty=load_empty) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index e614b33287c8fc..8e8686b506f476 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -4,7 +4,7 @@ import abc from collections import deque -from collections.abc import Callable, Container, Coroutine, Generator, Iterable +from collections.abc import Callable, Container, Coroutine, Generator, Iterable, Mapping from contextlib import contextmanager from dataclasses import dataclass from datetime import datetime, time as dt_time, timedelta @@ -76,6 +76,8 @@ from . import config_validation as cv, entity_registry as er, selector from .automation import ( + DomainSpec, + filter_by_domain_specs, get_absolute_description_key, get_relative_description_key, move_options_fields_to_top_level, @@ -332,10 +334,10 @@ async def async_get_checker(self) -> ConditionChecker: ) -class EntityConditionBase(Condition): +class EntityConditionBase[DomainSpecT: DomainSpec = DomainSpec](Condition): """Base class for entity conditions.""" - _domain: str + _domain_specs: Mapping[str, DomainSpecT] _schema: vol.Schema = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL @override @@ -356,12 +358,15 @@ def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: self._behavior = config.options[ATTR_BEHAVIOR] def entity_filter(self, entities: set[str]) -> set[str]: - """Filter entities of this domain.""" - return { - entity_id - for entity_id in entities - if split_entity_id(entity_id)[0] == self._domain - } + """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[split_entity_id(entity_state.entity_id)[0]] + if domain_spec.value_source is None: + return entity_state.state + return entity_state.attributes.get(domain_spec.value_source) @abc.abstractmethod def is_valid_state(self, entity_state: State) -> bool: @@ -412,13 +417,28 @@ class EntityStateConditionBase(EntityConditionBase): def is_valid_state(self, entity_state: State) -> bool: """Check if the state matches the expected state(s).""" - return entity_state.state in self._states + return self._get_tracked_value(entity_state) in self._states + + +def _normalize_domain_specs( + domain_specs: Mapping[str, DomainSpec] | str, +) -> Mapping[str, DomainSpec]: + """Normalize domain_specs argument to a Mapping.""" + if isinstance(domain_specs, str): + return {domain_specs: DomainSpec()} + return domain_specs def make_entity_state_condition( - domain: str, states: str | set[str] + domain_specs: Mapping[str, DomainSpec] | str, + states: str | set[str], ) -> type[EntityStateConditionBase]: - """Create a condition for entity state changes to specific state(s).""" + """Create a condition for entity state changes to specific state(s). + + domain_specs can be a string (domain name) for simple state-based conditions, + or a Mapping[str, DomainSpec] for attribute-based or multi-domain conditions. + """ + specs = _normalize_domain_specs(domain_specs) if isinstance(states, str): states_set = {states} @@ -428,43 +448,12 @@ def make_entity_state_condition( class CustomCondition(EntityStateConditionBase): """Condition for entity state.""" - _domain = domain + _domain_specs = specs _states = states_set return CustomCondition -class EntityStateAttributeConditionBase(EntityConditionBase): - """State attribute condition.""" - - _attribute: str - _attribute_states: set[str] - - def is_valid_state(self, entity_state: State) -> bool: - """Check if the state matches the expected state(s).""" - return entity_state.attributes.get(self._attribute) in self._attribute_states - - -def make_entity_state_attribute_condition( - domain: str, attribute: str, attribute_states: str | set[str] -) -> type[EntityStateAttributeConditionBase]: - """Create a condition for entity attribute matching specific state(s).""" - - if isinstance(attribute_states, str): - attribute_states_set = {attribute_states} - else: - attribute_states_set = attribute_states - - class CustomCondition(EntityStateAttributeConditionBase): - """Condition for entity attribute.""" - - _domain = domain - _attribute = attribute - _attribute_states = attribute_states_set - - return CustomCondition - - class ConditionProtocol(Protocol): """Define the format of condition modules.""" diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index 761a9c5714ec1e..7e38dff3a31af0 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -215,11 +215,19 @@ async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> config_entries.ConfigFlowResult: """Handle a user initiated set up flow to create a webhook.""" - if not self._allow_multiple and self._async_current_entries(): + if ( + not self._allow_multiple + and self._async_current_entries() + and self.source != config_entries.SOURCE_RECONFIGURE + ): return self.async_abort(reason="single_instance_allowed") if user_input is None: - return self.async_show_form(step_id="user") + return self.async_show_form( + step_id="reconfigure" + if self.source == config_entries.SOURCE_RECONFIGURE + else "user" + ) # Local import to be sure cloud is loaded and setup from homeassistant.components.cloud import ( # noqa: PLC0415 @@ -234,7 +242,11 @@ async def async_step_user( async_generate_url, ) - webhook_id = async_generate_id() + if self.source == config_entries.SOURCE_RECONFIGURE: + entry = self._get_reconfigure_entry() + webhook_id = entry.data["webhook_id"] + else: + webhook_id = async_generate_id() if "cloud" in self.hass.config.components and async_active_subscription( self.hass @@ -250,12 +262,30 @@ async def async_step_user( self._description_placeholder["webhook_url"] = webhook_url + if self.source == config_entries.SOURCE_RECONFIGURE: + if self.hass.config_entries.async_update_entry( + entry=entry, + data={**entry.data, "webhook_id": webhook_id, "cloudhook": cloudhook}, + ): + self.hass.config_entries.async_schedule_reload(entry.entry_id) + return self.async_abort( + reason="reconfigure_successful", + description_placeholders=self._description_placeholder, + ) + return self.async_create_entry( title=self._title, data={"webhook_id": webhook_id, "cloudhook": cloudhook}, description_placeholders=self._description_placeholder, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Handle a user initiated flow to re-configure a webhook.""" + + return await self.async_step_user(user_input) + def register_webhook_flow( domain: str, title: str, description_placeholder: dict, allow_multiple: bool = False diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index d7fc606b591e97..c5bce5779c5437 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -15,7 +15,7 @@ from collections.abc import Awaitable, Callable import hashlib from http import HTTPStatus -from json import JSONDecodeError +import json import logging import secrets import time @@ -29,7 +29,12 @@ from homeassistant import config_entries from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ( + HomeAssistantError, + OAuth2TokenRequestError, + OAuth2TokenRequestReauthError, + OAuth2TokenRequestTransientError, +) from homeassistant.loader import async_get_application_credentials from homeassistant.util.hass_dict import HassKey @@ -56,6 +61,7 @@ HEADER_FRONTEND_BASE = "HA-Frontend-Base" MY_AUTH_CALLBACK_PATH = "https://my.home-assistant.io/redirect/oauth" + CLOCK_OUT_OF_SYNC_MAX_SEC = 20 OAUTH_AUTHORIZE_URL_TIMEOUT_SEC = 30 @@ -134,7 +140,10 @@ async def async_refresh_token(self, token: dict) -> dict: @abstractmethod async def _async_refresh_token(self, token: dict) -> dict: - """Refresh a token.""" + """Refresh a token. + + Should raise OAuth2TokenRequestError on token refresh failure. + """ class LocalOAuth2Implementation(AbstractOAuth2Implementation): @@ -211,7 +220,8 @@ async def async_resolve_external_data(self, external_data: Any) -> dict: return await self._token_request(request_data) async def _async_refresh_token(self, token: dict) -> dict: - """Refresh tokens.""" + """Refresh a token.""" + new_token = await self._token_request( { "grant_type": "refresh_token", @@ -219,33 +229,76 @@ async def _async_refresh_token(self, token: dict) -> dict: "refresh_token": token["refresh_token"], } ) + return {**token, **new_token} async def _token_request(self, data: dict) -> dict: - """Make a token request.""" + """Make a token request. + + Raises OAuth2TokenRequestError on token request failure. + """ session = async_get_clientsession(self.hass) data["client_id"] = self.client_id - if self.client_secret: data["client_secret"] = self.client_secret _LOGGER.debug("Sending token request to %s", self.token_url) - resp = await session.post(self.token_url, data=data) - if resp.status >= 400: - try: - error_response = await resp.json() - except ClientError, JSONDecodeError: - error_response = {} - error_code = error_response.get("error", "unknown") - error_description = error_response.get("error_description", "unknown error") - _LOGGER.error( - "Token request for %s failed (%s): %s", - self.domain, - error_code, - error_description, - ) - resp.raise_for_status() + + try: + resp = await session.post(self.token_url, data=data) + if resp.status >= 400: + error_body = "" + try: + error_body = await resp.text() + error_data = json.loads(error_body) + error_code = error_data.get("error", "unknown error") + error_description = error_data.get("error_description") + detail = ( + f"{error_code}: {error_description}" + if error_description + else error_code + ) + except ClientError, ValueError, AttributeError: + detail = error_body[:200] if error_body else "unknown error" + _LOGGER.debug( + "Token request for %s failed (%s): %s", + self.domain, + resp.status, + detail, + ) + resp.raise_for_status() + except ClientResponseError as err: + if err.status == HTTPStatus.TOO_MANY_REQUESTS or 500 <= err.status <= 599: + # Recoverable error + raise OAuth2TokenRequestTransientError( + request_info=err.request_info, + history=err.history, + status=err.status, + message=err.message, + headers=err.headers, + domain=self._domain, + ) from err + if 400 <= err.status <= 499: + # Non-recoverable error + raise OAuth2TokenRequestReauthError( + request_info=err.request_info, + history=err.history, + status=err.status, + message=err.message, + headers=err.headers, + domain=self._domain, + ) from err + + raise OAuth2TokenRequestError( + request_info=err.request_info, + history=err.history, + status=err.status, + message=err.message, + headers=err.headers, + domain=self._domain, + ) from err + return cast(dict, await resp.json()) @@ -458,12 +511,12 @@ async def async_step_creation( except TimeoutError as err: _LOGGER.error("Timeout resolving OAuth token: %s", err) return self.async_abort(reason="oauth_timeout") - except (ClientResponseError, ClientError) as err: + except ( + OAuth2TokenRequestError, + ClientError, + ) as err: _LOGGER.error("Error resolving OAuth token: %s", err) - if ( - isinstance(err, ClientResponseError) - and err.status == HTTPStatus.UNAUTHORIZED - ): + if isinstance(err, OAuth2TokenRequestReauthError): return self.async_abort(reason="oauth_unauthorized") return self.async_abort(reason="oauth_failed") diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 13eef730735448..c8b384189df681 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -1461,7 +1461,7 @@ def async_remove_device(self, device_id: str) -> None: ) self.async_schedule_save() - async def async_load(self) -> None: + async def _async_load(self) -> None: """Load the device registry.""" async_setup_cleanup(self.hass, self) @@ -1706,10 +1706,10 @@ def async_get(hass: HomeAssistant) -> DeviceRegistry: return DeviceRegistry(hass) -async def async_load(hass: HomeAssistant) -> None: +async def async_load(hass: HomeAssistant, *, load_empty: bool = False) -> None: """Load device registry.""" assert DATA_REGISTRY not in hass.data - await async_get(hass).async_load() + await async_get(hass).async_load(load_empty=load_empty) @callback diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 30e9a04063254c..4652ca952971c8 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -1,8 +1,7 @@ """An abstract class for entities.""" -from __future__ import annotations - from abc import ABCMeta +from annotationlib import Format, get_annotations import asyncio from collections import deque from collections.abc import Callable, Coroutine, Iterable, Mapping @@ -27,6 +26,7 @@ ATTR_DEVICE_CLASS, ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME, + ATTR_GROUP_ENTITIES, ATTR_ICON, ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, @@ -54,13 +54,15 @@ from homeassistant.util import ensure_unique_string, slugify from homeassistant.util.frozen_dataclass_compat import FrozenOrThawed -from . import device_registry as dr, entity_registry as er, singleton +from . import device_registry as dr, entity_registry as er from .device_registry import DeviceInfo, EventDeviceRegistryUpdatedData from .event import ( async_track_device_registry_updated_event, async_track_entity_registry_updated_event, ) -from .frame import report_non_thread_safe_operation +from .frame import report_non_thread_safe_operation, report_usage +from .group import Group +from .singleton import singleton from .typing import UNDEFINED, StateType, UndefinedType timer = time.time @@ -90,7 +92,7 @@ def async_setup(hass: HomeAssistant) -> None: @callback @bind_hass -@singleton.singleton(DATA_ENTITY_SOURCE) +@singleton(DATA_ENTITY_SOURCE) def entity_sources(hass: HomeAssistant) -> dict[str, EntityInfo]: """Get the entity sources. @@ -166,6 +168,16 @@ def get_device_class(hass: HomeAssistant, entity_id: str) -> str | None: return entry.device_class or entry.original_device_class +def get_device_class_or_undefined( + hass: HomeAssistant, entity_id: str +) -> str | None | UndefinedType: + """Get the device class of an entity or UNDEFINED if not found.""" + try: + return get_device_class(hass, entity_id) + except HomeAssistantError: + return UNDEFINED + + def get_supported_features(hass: HomeAssistant, entity_id: str) -> int: """Get supported features for an entity. @@ -368,9 +380,20 @@ def wrap_attr(cls: CachedProperties, property_name: str) -> None: if isinstance(attr, (FunctionType, property)): raise TypeError(f"Can't override {attr_name} in subclass") setattr(cls, private_attr_name, attr) - annotations = cls.__annotations__ + annotations = get_annotations(cls, format=Format.FORWARDREF) if attr_name in annotations: annotations[private_attr_name] = annotations.pop(attr_name) + + if "__annotations__" in cls.__dict__: + cls.__annotations__ = annotations + else: + + def wrapped_annotate(format: Format) -> dict[str, Any]: + # Note: to avoid complicating things, we only support FORWARDREF + return annotations + + cls.__annotate__ = wrapped_annotate + # Create the _attr_ property setattr(cls, attr_name, make_property(property_name)) @@ -457,6 +480,15 @@ class Entity( # Only handled internally, never to be used by integrations. internal_integration_suggested_object_id: str | None + # A group information in case the entity represents a group + group: Group | None = None + # Internal copy of `group`. This prevents integration authors from + # mistakenly overwriting it during the entity's lifetime, which would + # break Group functionality. It also lets us check if `group` is + # actually a Group instance just once in `async_internal_added_to_hass`, + # rather than on every state write. + __group: Group | None = None + # If we reported if this entity was slow _slow_reported = False @@ -1064,6 +1096,10 @@ def __async_calculate_state( entry = self.registry_entry capability_attr = self.capability_attributes + if self.__group is not None: + capability_attr = capability_attr.copy() if capability_attr else {} + capability_attr[ATTR_GROUP_ENTITIES] = self.__group.member_entity_ids.copy() + attr = capability_attr.copy() if capability_attr else {} available = self.available # only call self.available once per update cycle @@ -1503,6 +1539,17 @@ async def async_internal_added_to_hass(self) -> None: ) self._async_subscribe_device_updates() + if self.group is not None: + if not isinstance(self.group, Group): + report_usage( # type: ignore[unreachable] + f"sets a `group` attribute on entity {self.entity_id} which is " + "not a `Group` instance", + breaks_in_ha_version="2027.2", + ) + else: + self.__group = self.group + self.__group.async_added_to_hass() + async def async_internal_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass. @@ -1513,6 +1560,9 @@ async def async_internal_will_remove_from_hass(self) -> None: if self.platform: del entity_sources(self.hass)[self.entity_id] + if self.__group is not None: + self.__group.async_will_remove_from_hass() + @callback def _async_registry_updated( self, event: Event[er.EventEntityRegistryUpdatedData] diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index cf13e27c2cff6d..ca46be3d93462d 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -24,7 +24,11 @@ SupportsResponse, callback, ) -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ( + ConfigValidationError, + HomeAssistantError, + ServiceValidationError, +) from homeassistant.loader import async_get_integration, bind_hass from homeassistant.setup import async_prepare_setup_platform from homeassistant.util.hass_dict import HassKey @@ -301,27 +305,31 @@ async def async_remove_entity(self, entity_id: str) -> None: if found: await found.async_remove_entity(entity_id) - async def async_prepare_reload( - self, *, skip_reset: bool = False - ) -> ConfigType | None: + async def async_prepare_reload(self, *, skip_reset: bool = False) -> ConfigType: """Prepare reloading this entity component. - This method must be run in the event loop. + This method is intended to be called from service handlers implementing reload. + Will raise ServiceValidationError if the config is not valid. """ try: conf = await conf_util.async_hass_config_yaml(self.hass) except HomeAssistantError as err: - self.logger.error(err) - return None + raise ServiceValidationError( + f"Failed to load configuration: {err}" + ) from err integration = await async_get_integration(self.hass, self.domain) - processed_conf = await conf_util.async_process_component_and_handle_errors( - self.hass, conf, integration - ) - - if processed_conf is None: - return None + try: + processed_conf = await conf_util.async_process_component_and_handle_errors( + self.hass, conf, integration, raise_on_failure=True + ) + except ConfigValidationError as err: + raise ServiceValidationError( + translation_domain=err.translation_domain, + translation_key=err.translation_key, + translation_placeholders=err.translation_placeholders, + ) from err if not skip_reset: await self._async_reset() diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 8150c790c4608e..9f27ee5b13912e 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -13,7 +13,7 @@ from collections import defaultdict from collections.abc import Callable, Hashable, KeysView, Mapping from datetime import datetime, timedelta -from enum import StrEnum +from enum import Enum, StrEnum import logging import time from typing import TYPE_CHECKING, Any, Literal, NotRequired, TypedDict @@ -80,7 +80,7 @@ _LOGGER = logging.getLogger(__name__) STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 20 +STORAGE_VERSION_MINOR = 21 STORAGE_KEY = "core.entity_registry" CLEANUP_INTERVAL = 3600 * 24 @@ -91,6 +91,28 @@ } ENTITY_CATEGORY_INDEX_TO_VALUE = dict(enumerate(EntityCategory)) + +class ComputedNameType(Enum): + """Singleton representing the computed full entity name in aliases.""" + + _singleton = 0 + + +COMPUTED_NAME = ComputedNameType._singleton # noqa: SLF001 + +type AliasEntry = str | ComputedNameType + + +def _serialize_aliases(aliases: list[AliasEntry]) -> list[str | None]: + """Convert aliases to a JSON-serializable list.""" + return [None if a is COMPUTED_NAME else a for a in aliases] + + +def _deserialize_aliases(aliases: list[str | None]) -> list[AliasEntry]: + """Convert aliases from JSON to internal representation.""" + return [COMPUTED_NAME if a is None else a for a in aliases] + + # Attributes relevant to describing entity # to external services. ENTITY_DESCRIBING_ATTRIBUTES = { @@ -184,7 +206,7 @@ class RegistryEntry: unique_id: str = attr.ib() platform: str = attr.ib() previous_unique_id: str | None = attr.ib(default=None) - aliases: set[str] = attr.ib(factory=set) + aliases: list[AliasEntry] = attr.ib(factory=list) area_id: str | None = attr.ib(default=None) categories: dict[str, str] = attr.ib(factory=dict) capabilities: Mapping[str, Any] | None = attr.ib() @@ -215,6 +237,11 @@ class RegistryEntry: supported_features: int = attr.ib() translation_key: str | None = attr.ib() unit_of_measurement: str | None = attr.ib() + + # For backwards compatibility, should be removed in the future + compat_aliases: list[str] = attr.ib(factory=list, eq=False) + compat_name: str | None = attr.ib(default=None, eq=False) + _cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False) @domain.default @@ -252,7 +279,7 @@ def _as_display_dict(self) -> dict[str, Any] | None: display_dict["hb"] = True if self.has_entity_name: display_dict["hn"] = True - name = self.name or self.original_name + name = self.name if self.name is not None else self.original_name if name is not None: display_dict["en"] = name if self.domain == "sensor" and (sensor_options := self.options.get("sensor")): @@ -320,7 +347,7 @@ def extended_dict(self) -> dict[str, Any]: # it every time return { **self.as_partial_dict, - "aliases": list(self.aliases), + "aliases": _serialize_aliases(self.aliases), "capabilities": self.capabilities, "device_class": self.device_class, "original_device_class": self.original_device_class, @@ -349,7 +376,8 @@ def as_storage_fragment(self) -> json_fragment: return json_fragment( json_bytes( { - "aliases": list(self.aliases), + "aliases": self.compat_aliases, + "aliases_v2": _serialize_aliases(self.aliases), "area_id": self.area_id, "categories": self.categories, "capabilities": self.capabilities, @@ -367,7 +395,8 @@ def as_storage_fragment(self) -> json_fragment: "has_entity_name": self.has_entity_name, "labels": list(self.labels), "modified_at": self.modified_at, - "name": self.name, + "name": self.compat_name, + "name_v2": self.name, "object_id_base": self.object_id_base, "options": self.options, "original_device_class": self.original_device_class, @@ -414,7 +443,7 @@ def write_unavailable_state(self, hass: HomeAssistant) -> None: @callback -def _async_get_full_entity_name_generic( +def _async_get_full_entity_name( hass: HomeAssistant, *, device_id: str | None, @@ -430,13 +459,14 @@ def _async_get_full_entity_name_generic( Used for both full entity name and entity ID. """ use_device = False - if name is None: - if overridden_name is not None: - name = overridden_name - else: - name = original_name - if has_entity_name: - use_device = True + if name is not None: + use_device = True + elif overridden_name is not None: + name = overridden_name + else: + name = original_name + if has_entity_name: + use_device = True device = ( dr.async_get(hass).async_get(device_id) @@ -467,7 +497,7 @@ def async_get_full_entity_name( original_name = ( original_name if original_name is not UNDEFINED else entry.original_name ) - return _async_get_full_entity_name_generic( + return _async_get_full_entity_name( hass, device_id=entry.device_id, fallback="", @@ -477,6 +507,82 @@ def async_get_full_entity_name( ) +@callback +def async_get_entity_aliases( + hass: HomeAssistant, + entry: RegistryEntry, + *, + allow_empty: bool = True, +) -> list[str]: + """Get all names/aliases for an entity. + + Processes entry aliases where COMPUTED_NAME entries are replaced with the + computed full entity name. String entries are used as-is. + + The returned list preserves the order set by the user. + """ + entry_aliases = entry.aliases + if not entry_aliases: + if allow_empty: + return [] + entry_aliases = [COMPUTED_NAME] + + aliases = [] + for alias in entry_aliases: + if alias is COMPUTED_NAME: + alias = async_get_full_entity_name(hass, entry) + aliases.append(alias.strip()) + + return aliases + + +@callback +def _async_strip_prefix_from_entity_name( + entity_name: str | None, prefix: str | None +) -> str | None: + """Strip prefix from entity name. + + Returns None if the prefix does not meaningfully match. + """ + if not entity_name or not prefix: + return None + + prefix_lower = prefix.casefold() + prefix_len = len(prefix_lower) + + candidate = entity_name[:prefix_len] + true_prefix_len = len(candidate) + candidate = candidate.casefold() + + if not candidate.startswith(prefix_lower): + return None + + # Casefolded string can differ in length + prefix_diff = len(candidate) - prefix_len + while prefix_diff > 0: + true_prefix_len -= 1 + prefix_diff -= len(entity_name[true_prefix_len].casefold()) + + # Casefolded string matched in a middle of a character, not a valid prefix + if prefix_diff < 0: + return None + + new_name = entity_name[true_prefix_len:].lstrip(" -:") + + if not new_name: + return "" + + # Must have at least one separator character + if len(new_name) == len(entity_name) - true_prefix_len: + return None + + first_word = new_name.partition(" ")[0] + # Preserve a mixed-case word, capitalize lowercase + if not first_word.islower(): + return new_name + return new_name[0].upper() + new_name[1:] + + @attr.s(frozen=True, slots=True) class DeletedRegistryEntry: """Deleted Entity Registry Entry.""" @@ -485,7 +591,7 @@ class DeletedRegistryEntry: unique_id: str = attr.ib() platform: str = attr.ib() - aliases: set[str] = attr.ib() + aliases: list[AliasEntry] = attr.ib() area_id: str | None = attr.ib() categories: dict[str, str] = attr.ib() config_entry_id: str | None = attr.ib() @@ -505,6 +611,10 @@ class DeletedRegistryEntry: ) orphaned_timestamp: float | None = attr.ib() + # For backwards compatibility, should be removed in the future + compat_aliases: list[str] = attr.ib(factory=list, eq=False) + compat_name: str | None = attr.ib(default=None, eq=False) + _cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False) @domain.default @@ -518,7 +628,8 @@ def as_storage_fragment(self) -> json_fragment: return json_fragment( json_bytes( { - "aliases": list(self.aliases), + "aliases": self.compat_aliases, + "aliases_v2": _serialize_aliases(self.aliases), "area_id": self.area_id, "categories": self.categories, "config_entry_id": self.config_entry_id, @@ -538,7 +649,8 @@ def as_storage_fragment(self) -> json_fragment: "id": self.id, "labels": list(self.labels), "modified_at": self.modified_at, - "name": self.name, + "name": self.compat_name, + "name_v2": self.name, "options": self.options if self.options is not UNDEFINED else {}, "options_undefined": self.options is UNDEFINED, "orphaned_timestamp": self.orphaned_timestamp, @@ -691,6 +803,48 @@ async def _async_migrate_func( # noqa: C901 for entity in data["entities"]: entity["object_id_base"] = entity["original_name"] + if old_minor_version < 21: + # Version 1.21 migrates the full name to include device name, + # even if entity name is overwritten by user. + # It also adds support for COMPUTED_NAME in aliases and starts preserving their order. + # To avoid a major version bump, we keep the old name and aliases as-is + # and use new name_v2 and aliases_v2 fields instead. + device_registry = dr.async_get(self.hass) + + for entity in data["entities"]: + alias_to_add: str | None = None + if ( + (name := entity["name"]) + and (device_id := entity["device_id"]) is not None + and (device := device_registry.async_get(device_id)) is not None + and (device_name := device.name_by_user or device.name) + ): + # Strip the device name prefix from the entity name if present, + # and add the full generated name as an alias. + # If the name doesn't have the device name prefix and the + # entity is exposed to a voice assistant, add the previous + # name as an alias instead to preserve backwards compatibility. + if ( + new_name := _async_strip_prefix_from_entity_name( + name, device_name + ) + ) is not None: + name = new_name + elif any( + entity.get("options", {}).get(key, {}).get("should_expose") + for key in ("conversation", "cloud.google_assistant") + ): + alias_to_add = name + + entity["name_v2"] = name + entity["aliases_v2"] = [alias_to_add, *entity["aliases"]] + + for entity in data["deleted_entities"]: + # We don't know what the device name was, so the only thing we can do + # is to clear the overwritten name to not mislead users. + entity["name_v2"] = None + entity["aliases_v2"] = [None, *entity["aliases"]] + if old_major_version > 1: raise NotImplementedError return data @@ -1029,13 +1183,15 @@ def _async_generate_entity_id( `name` is the name set by the user, not the original name from the integration. `name` has priority over `suggested_object_id`, which has priority over `object_id_base`. - `name` and `suggested_object_id` will never be prefixed with the device name, - `object_id_base` will be if `has_entity_name` is True. + `name` will always be prefixed with the device name. + `suggested_object_id` will not be prefixed with the device name. + `object_id_base` will be prefixed with the device name if + `has_entity_name` is True. Entity ID conflicts are checked against registered and currently existing entities, as well as provided `reserved_entity_ids`. """ - object_id = _async_get_full_entity_name_generic( + object_id = _async_get_full_entity_name( self.hass, device_id=device_id, fallback=f"{platform}_{unique_id}", @@ -1159,6 +1315,8 @@ def async_get_or_create( aliases = deleted_entity.aliases area_id = deleted_entity.area_id categories = deleted_entity.categories + compat_aliases = deleted_entity.compat_aliases + compat_name = deleted_entity.compat_name created_at = deleted_entity.created_at device_class = deleted_entity.device_class if deleted_entity.disabled_by is not UNDEFINED: @@ -1186,9 +1344,11 @@ def async_get_or_create( else: options = get_initial_options() if get_initial_options else None else: - aliases = set() + aliases = [COMPUTED_NAME] area_id = None categories = {} + compat_aliases = [] + compat_name = None device_class = None icon = None labels = set() @@ -1230,6 +1390,8 @@ def none_if_undefined[_T](value: _T | UndefinedType) -> _T | None: area_id=area_id, categories=categories, capabilities=none_if_undefined(capabilities), + compat_aliases=compat_aliases, + compat_name=compat_name, config_entry_id=none_if_undefined(config_entry_id), config_subentry_id=none_if_undefined(config_subentry_id), created_at=created_at, @@ -1290,6 +1452,8 @@ def async_remove(self, entity_id: str) -> None: aliases=entity.aliases, area_id=entity.area_id, categories=entity.categories, + compat_aliases=entity.compat_aliases, + compat_name=entity.compat_name, config_entry_id=config_entry_id, config_subentry_id=entity.config_subentry_id, created_at=entity.created_at, @@ -1422,7 +1586,7 @@ def _async_update_entity( self, entity_id: str, *, - aliases: set[str] | UndefinedType = UNDEFINED, + aliases: list[AliasEntry] | UndefinedType = UNDEFINED, area_id: str | None | UndefinedType = UNDEFINED, categories: dict[str, str] | UndefinedType = UNDEFINED, capabilities: Mapping[str, Any] | None | UndefinedType = UNDEFINED, @@ -1573,7 +1737,7 @@ def async_update_entity( self, entity_id: str, *, - aliases: set[str] | UndefinedType = UNDEFINED, + aliases: list[AliasEntry] | UndefinedType = UNDEFINED, area_id: str | None | UndefinedType = UNDEFINED, categories: dict[str, str] | UndefinedType = UNDEFINED, capabilities: Mapping[str, Any] | None | UndefinedType = UNDEFINED, @@ -1678,7 +1842,7 @@ def async_update_entity_options( new_options[domain] = options return self._async_update_entity(entity_id, options=new_options) - async def async_load(self) -> None: + async def _async_load(self) -> None: """Load the entity registry.""" _async_setup_cleanup(self.hass, self) _async_setup_entity_restore(self.hass, self) @@ -1715,10 +1879,12 @@ async def async_load(self) -> None: continue entities[entity["entity_id"]] = RegistryEntry( - aliases=set(entity["aliases"]), + aliases=_deserialize_aliases(entity["aliases_v2"]), area_id=entity["area_id"], categories=entity["categories"], capabilities=entity["capabilities"], + compat_aliases=entity["aliases"], + compat_name=entity["name"], config_entry_id=entity["config_entry_id"], config_subentry_id=entity["config_subentry_id"], created_at=datetime.fromisoformat(entity["created_at"]), @@ -1739,7 +1905,7 @@ async def async_load(self) -> None: has_entity_name=entity["has_entity_name"], labels=set(entity["labels"]), modified_at=datetime.fromisoformat(entity["modified_at"]), - name=entity["name"], + name=entity["name_v2"], object_id_base=entity.get("object_id_base"), options=entity["options"], original_device_class=entity["original_device_class"], @@ -1785,9 +1951,11 @@ def get_optional_enum[_EnumT: StrEnum]( entity["unique_id"], ) deleted_entities[key] = DeletedRegistryEntry( - aliases=set(entity["aliases"]), + aliases=_deserialize_aliases(entity["aliases_v2"]), area_id=entity["area_id"], categories=entity["categories"], + compat_aliases=entity["aliases"], + compat_name=entity["name"], config_entry_id=entity["config_entry_id"], config_subentry_id=entity["config_subentry_id"], created_at=datetime.fromisoformat(entity["created_at"]), @@ -1807,7 +1975,7 @@ def get_optional_enum[_EnumT: StrEnum]( id=entity["id"], labels=set(entity["labels"]), modified_at=datetime.fromisoformat(entity["modified_at"]), - name=entity["name"], + name=entity["name_v2"], options=entity["options"] if not entity["options_undefined"] else UNDEFINED, @@ -1945,10 +2113,10 @@ def async_get(hass: HomeAssistant) -> EntityRegistry: return EntityRegistry(hass) -async def async_load(hass: HomeAssistant) -> None: +async def async_load(hass: HomeAssistant, *, load_empty: bool = False) -> None: """Load entity registry.""" assert DATA_REGISTRY not in hass.data - await async_get(hass).async_load() + await async_get(hass).async_load(load_empty=load_empty) @callback diff --git a/homeassistant/helpers/floor_registry.py b/homeassistant/helpers/floor_registry.py index 2f4c4cdee36aae..aae2a08e81e6a1 100644 --- a/homeassistant/helpers/floor_registry.py +++ b/homeassistant/helpers/floor_registry.py @@ -94,7 +94,7 @@ async def _async_migrate_func( ) -> FloorRegistryStoreData: """Migrate to the new version.""" if old_major_version > STORAGE_VERSION_MAJOR: - raise ValueError("Can't migrate to future version") + raise NotImplementedError if old_major_version == 1: if old_minor_version < 2: @@ -307,7 +307,7 @@ def async_reorder(self, floor_ids: list[str]) -> None: _EventFloorRegistryUpdatedData_Reorder(action="reorder"), ) - async def async_load(self) -> None: + async def _async_load(self) -> None: """Load the floor registry.""" data = await self._store.async_load() floors = FloorRegistryItems() @@ -353,7 +353,7 @@ def async_get(hass: HomeAssistant) -> FloorRegistry: return FloorRegistry(hass) -async def async_load(hass: HomeAssistant) -> None: +async def async_load(hass: HomeAssistant, *, load_empty: bool = False) -> None: """Load floor registry.""" assert DATA_REGISTRY not in hass.data - await async_get(hass).async_load() + await async_get(hass).async_load(load_empty=load_empty) diff --git a/homeassistant/helpers/group.py b/homeassistant/helpers/group.py index 7d4eeb6d133d35..939d1c1cafd967 100644 --- a/homeassistant/helpers/group.py +++ b/homeassistant/helpers/group.py @@ -3,19 +3,167 @@ from __future__ import annotations from collections.abc import Iterable -from typing import Any +from typing import TYPE_CHECKING, Any + +from propcache.api import cached_property from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, ENTITY_MATCH_NONE -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback + +from . import entity_registry as er +from .singleton import singleton + +if TYPE_CHECKING: + from .entity import Entity +DATA_GROUP_ENTITIES = "group_entities" ENTITY_PREFIX = "group." +class Group: + """Entity group base class.""" + + _entity: Entity + + def __init__(self, entity: Entity) -> None: + """Initialize the group.""" + self._entity = entity + + @property + def member_entity_ids(self) -> list[str]: + """Return the list of member entity IDs.""" + raise NotImplementedError + + @callback + def async_added_to_hass(self) -> None: + """Called when the entity is added to hass.""" + entity = self._entity + get_group_entities(entity.hass)[entity.entity_id] = entity + + @callback + def async_will_remove_from_hass(self) -> None: + """Called when the entity will be removed from hass.""" + entity = self._entity + del get_group_entities(entity.hass)[entity.entity_id] + + +class GenericGroup(Group): + """Generic entity group. + + Members can come from multiple integrations and are referenced by entity ID. + """ + + def __init__(self, entity: Entity, member_entity_ids: list[str]) -> None: + """Initialize the group.""" + super().__init__(entity) + self._member_entity_ids = member_entity_ids + + @cached_property + def member_entity_ids(self) -> list[str]: + """Return the list of member entity IDs.""" + return self._member_entity_ids + + +class IntegrationSpecificGroup(Group): + """Integration-specific entity group. + + Members come from a single integration and are referenced by unique ID. + Entity IDs are resolved via the entity registry. This group listens for + entity registry events to keep the resolved entity IDs up to date. + """ + + _member_entity_ids: list[str] | None = None + _member_unique_ids: list[str] + + def __init__(self, entity: Entity, member_unique_ids: list[str]) -> None: + """Initialize the group.""" + super().__init__(entity) + self._member_unique_ids = member_unique_ids + + @cached_property + def member_entity_ids(self) -> list[str]: + """Return the list of member entity IDs.""" + entity_registry = er.async_get(self._entity.hass) + self._member_entity_ids = [ + entity_id + for unique_id in self.member_unique_ids + if ( + entity_id := entity_registry.async_get_entity_id( + self._entity.platform.domain, + self._entity.platform.platform_name, + unique_id, + ) + ) + is not None + ] + return self._member_entity_ids + + @property + def member_unique_ids(self) -> list[str]: + """Return the list of member unique IDs.""" + return self._member_unique_ids + + @member_unique_ids.setter + def member_unique_ids(self, value: list[str]) -> None: + """Set the list of member unique IDs.""" + self._member_unique_ids = value + if self._member_entity_ids is not None: + self._member_entity_ids = None + del self.member_entity_ids + + @callback + def async_added_to_hass(self) -> None: + """Called when the entity is added to hass.""" + super().async_added_to_hass() + + entity = self._entity + entity_registry = er.async_get(entity.hass) + + @callback + def _handle_entity_registry_updated(event: Event[Any]) -> None: + """Handle registry create or update event.""" + if ( + event.data["action"] in {"create", "update"} + and (entry := entity_registry.async_get(event.data["entity_id"])) + and entry.domain == entity.platform.domain + and entry.platform == entity.platform.platform_name + and entry.unique_id in self.member_unique_ids + ) or ( + event.data["action"] == "remove" + and self._member_entity_ids is not None + and event.data["entity_id"] in self._member_entity_ids + ): + if self._member_entity_ids is not None: + self._member_entity_ids = None + del self.member_entity_ids + entity.async_write_ha_state() + + entity.async_on_remove( + entity.hass.bus.async_listen( + er.EVENT_ENTITY_REGISTRY_UPDATED, + _handle_entity_registry_updated, + ) + ) + + +@callback +@singleton(DATA_GROUP_ENTITIES) +def get_group_entities(hass: HomeAssistant) -> dict[str, Entity]: + """Get the group entities. + + Items are added to this dict by Group.async_added_to_hass and + removed by Group.async_will_remove_from_hass. + """ + return {} + + def expand_entity_ids(hass: HomeAssistant, entity_ids: Iterable[Any]) -> list[str]: """Return entity_ids with group entity ids replaced by their members. Async friendly. """ + group_entities = get_group_entities(hass) + found_ids: list[str] = [] for entity_id in entity_ids: if not isinstance(entity_id, str) or entity_id in ( @@ -25,8 +173,22 @@ def expand_entity_ids(hass: HomeAssistant, entity_ids: Iterable[Any]) -> list[st continue entity_id = entity_id.lower() + # If entity_id points at a group, expand it - if entity_id.startswith(ENTITY_PREFIX): + if (entity := group_entities.get(entity_id)) is not None and isinstance( + entity.group, GenericGroup + ): + child_entities = entity.group.member_entity_ids + if entity_id in child_entities: + child_entities = list(child_entities) + child_entities.remove(entity_id) + found_ids.extend( + ent_id + for ent_id in expand_entity_ids(hass, child_entities) + if ent_id not in found_ids + ) + # If entity_id points at an old-style group, expand it + elif entity_id.startswith(ENTITY_PREFIX): child_entities = get_entity_ids(hass, entity_id) if entity_id in child_entities: child_entities = list(child_entities) diff --git a/homeassistant/helpers/importlib.py b/homeassistant/helpers/importlib.py index a4886f8aac57a2..3953881532d756 100644 --- a/homeassistant/helpers/importlib.py +++ b/homeassistant/helpers/importlib.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -from contextlib import suppress import importlib import logging import sys @@ -53,11 +52,10 @@ async def async_import_module(hass: HomeAssistant, name: str) -> ModuleType: if isinstance(ex, ModuleNotFoundError): failure_cache[name] = True import_future.set_exception(ex) - with suppress(BaseException): - # Set the exception retrieved flag on the future since - # it will never be retrieved unless there - # are concurrent calls - import_future.result() + # Set the exception retrieved flag on the future since + # it will never be retrieved unless there + # are concurrent calls + import_future.exception() raise finally: del import_futures[name] diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 97a41552f90954..62d83643fb241a 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -184,6 +184,52 @@ class IntentUnexpectedError(IntentError): """Unexpected error while handling intent.""" +class MatchFailedError(IntentError): + """Error when target matching fails.""" + + def __init__( + self, + result: MatchTargetsResult, + constraints: MatchTargetsConstraints, + preferences: MatchTargetsPreferences | None = None, + ) -> None: + """Initialize error.""" + super().__init__() + + self.result = result + self.constraints = constraints + self.preferences = preferences + + def __str__(self) -> str: + """Return string representation.""" + return f"" + + +class NoStatesMatchedError(MatchFailedError): + """Error when no states match the intent's constraints.""" + + def __init__( + self, + reason: MatchFailedReason, + name: str | None = None, + area: str | None = None, + floor: str | None = None, + domains: set[str] | None = None, + device_classes: set[str] | None = None, + ) -> None: + """Initialize error.""" + super().__init__( + result=MatchTargetsResult(False, reason), + constraints=MatchTargetsConstraints( + name=name, + area_name=area, + floor_name=floor, + domains=domains, + device_classes=device_classes, + ), + ) + + class MatchFailedReason(Enum): """Possible reasons for match failure in async_match_targets.""" @@ -232,6 +278,29 @@ def is_no_entities_reason(self) -> bool: ) +@dataclass +class MatchTargetsResult: + """Result from async_match_targets.""" + + is_match: bool + """True if one or more entities matched.""" + + no_match_reason: MatchFailedReason | None = None + """Reason for failed match when is_match = False.""" + + states: list[State] = field(default_factory=list) + """List of matched entity states.""" + + no_match_name: str | None = None + """Name of invalid area/floor or duplicate name when match fails for those reasons.""" + + areas: list[ar.AreaEntry] = field(default_factory=list) + """Areas that were targeted.""" + + floors: list[fr.FloorEntry] = field(default_factory=list) + """Floors that were targeted.""" + + @dataclass class MatchTargetsConstraints: """Constraints for async_match_targets.""" @@ -292,75 +361,6 @@ class MatchTargetsPreferences: """Id of floor to use when deduplicating names.""" -@dataclass -class MatchTargetsResult: - """Result from async_match_targets.""" - - is_match: bool - """True if one or more entities matched.""" - - no_match_reason: MatchFailedReason | None = None - """Reason for failed match when is_match = False.""" - - states: list[State] = field(default_factory=list) - """List of matched entity states.""" - - no_match_name: str | None = None - """Name of invalid area/floor or duplicate name when match fails for those reasons.""" - - areas: list[ar.AreaEntry] = field(default_factory=list) - """Areas that were targeted.""" - - floors: list[fr.FloorEntry] = field(default_factory=list) - """Floors that were targeted.""" - - -class MatchFailedError(IntentError): - """Error when target matching fails.""" - - def __init__( - self, - result: MatchTargetsResult, - constraints: MatchTargetsConstraints, - preferences: MatchTargetsPreferences | None = None, - ) -> None: - """Initialize error.""" - super().__init__() - - self.result = result - self.constraints = constraints - self.preferences = preferences - - def __str__(self) -> str: - """Return string representation.""" - return f"" - - -class NoStatesMatchedError(MatchFailedError): - """Error when no states match the intent's constraints.""" - - def __init__( - self, - reason: MatchFailedReason, - name: str | None = None, - area: str | None = None, - floor: str | None = None, - domains: set[str] | None = None, - device_classes: set[str] | None = None, - ) -> None: - """Initialize error.""" - super().__init__( - result=MatchTargetsResult(False, reason), - constraints=MatchTargetsConstraints( - name=name, - area_name=area, - floor_name=floor, - domains=domains, - device_classes=device_classes, - ), - ) - - @dataclass class MatchTargetsCandidate: """Candidate for async_match_targets.""" @@ -415,6 +415,7 @@ def _normalize_name(name: str) -> str: def _filter_by_name( + hass: HomeAssistant, name: str, candidates: Iterable[MatchTargetsCandidate], ) -> Iterable[MatchTargetsCandidate]: @@ -422,31 +423,19 @@ def _filter_by_name( name_norm = _normalize_name(name) for candidate in candidates: - # Accept name or entity id - if (candidate.state.entity_id == name) or _normalize_name( - candidate.state.name - ) == name_norm: + # Accept entity id + if candidate.state.entity_id == name: candidate.matched_name = name yield candidate continue - if candidate.entity is None: - continue - - if candidate.entity.name and ( - _normalize_name(candidate.entity.name) == name_norm + for candidate_name in async_get_entity_aliases( + hass, candidate.entity, state=candidate.state ): - candidate.matched_name = name - yield candidate - continue - - # Check aliases - if candidate.entity.aliases: - for alias in candidate.entity.aliases: - if _normalize_name(alias) == name_norm: - candidate.matched_name = name - yield candidate - break + if _normalize_name(candidate_name) == name_norm: + candidate.matched_name = name + yield candidate + break def _filter_by_features( @@ -583,7 +572,7 @@ def async_match_targets( # noqa: C901 if constraints.name: # Filter by entity name or alias - candidates = list(_filter_by_name(constraints.name, candidates)) + candidates = list(_filter_by_name(hass, constraints.name, candidates)) if not candidates: return MatchTargetsResult(False, MatchFailedReason.NAME) @@ -915,7 +904,7 @@ class DynamicServiceIntentHandler(IntentHandler): def __init__( self, intent_type: str, - speech: str | None = None, + *, required_slots: _IntentSlotsType | None = None, optional_slots: _IntentSlotsType | None = None, required_domains: set[str] | None = None, @@ -927,7 +916,6 @@ def __init__( ) -> None: """Create Service Intent Handler.""" self.intent_type = intent_type - self.speech = speech self.required_domains = required_domains self.required_features = required_features self.required_states = required_states @@ -1114,7 +1102,6 @@ async def async_handle_states( ) for floor in match_result.floors ) - speech_name = match_result.floors[0].name elif match_result.areas: success_results.extend( IntentResponseTarget( @@ -1122,9 +1109,6 @@ async def async_handle_states( ) for area in match_result.areas ) - speech_name = match_result.areas[0].name - else: - speech_name = states[0].name service_coros: list[Coroutine[Any, Any, None]] = [] for state in states: @@ -1166,9 +1150,6 @@ async def async_handle_states( states = [hass.states.get(state.entity_id) or state for state in states] response.async_set_states(states) - if self.speech is not None: - response.async_set_speech(self.speech.format(speech_name)) - return response async def async_call_service( @@ -1231,7 +1212,7 @@ def __init__( intent_type: str, domain: str, service: str, - speech: str | None = None, + *, required_slots: _IntentSlotsType | None = None, optional_slots: _IntentSlotsType | None = None, required_domains: set[str] | None = None, @@ -1244,7 +1225,6 @@ def __init__( """Create service handler.""" super().__init__( intent_type, - speech=speech, required_slots=required_slots, optional_slots=optional_slots, required_domains=required_domains, @@ -1391,7 +1371,6 @@ def __init__( self.reprompt: dict[str, dict[str, Any]] = {} self.card: dict[str, dict[str, str]] = {} self.error_code: IntentResponseErrorCode | None = None - self.intent_targets: list[IntentResponseTarget] = [] self.success_results: list[IntentResponseTarget] = [] self.failed_results: list[IntentResponseTarget] = [] self.matched_states: list[State] = [] @@ -1441,14 +1420,6 @@ def async_set_error(self, code: IntentResponseErrorCode, message: str) -> None: # Speak error message self.async_set_speech(message) - @callback - def async_set_targets( - self, - intent_targets: list[IntentResponseTarget], - ) -> None: - """Set response targets.""" - self.intent_targets = intent_targets - @callback def async_set_results( self, @@ -1494,11 +1465,6 @@ def as_dict(self) -> dict[str, Any]: response_data["code"] = self.error_code.value else: # action done or query answer - response_data["targets"] = [ - dataclasses.asdict(target) for target in self.intent_targets - ] - - # Add success/failed targets response_data["success"] = [ dataclasses.asdict(target) for target in self.success_results ] @@ -1510,3 +1476,25 @@ def as_dict(self) -> dict[str, Any]: response_dict["data"] = response_data return response_dict + + +@callback +def async_get_entity_aliases( + hass: HomeAssistant, + entity_entry: er.RegistryEntry | None, + *, + state: State, + allow_empty: bool = True, +) -> list[str]: + """Get all names/aliases for an entity. + + If no entity registry entry is provided, returns a list with just the + state name. Otherwise, delegates to the entity registry to resolve aliases, + where COMPUTED_NAME aliases are replaced with the computed full entity name. + + The returned list preserves the order set by the user. + """ + if entity_entry is None: + return [state.name.strip()] + + return er.async_get_entity_aliases(hass, entity_entry, allow_empty=allow_empty) diff --git a/homeassistant/helpers/issue_registry.py b/homeassistant/helpers/issue_registry.py index 1a1373e19efe5d..ce12d1f19da760 100644 --- a/homeassistant/helpers/issue_registry.py +++ b/homeassistant/helpers/issue_registry.py @@ -251,7 +251,7 @@ def make_read_only(self) -> None: """ self._store.make_read_only() - async def async_load(self) -> None: + async def _async_load(self) -> None: """Load the issue registry.""" data = await self._store.async_load() @@ -314,12 +314,17 @@ def async_get(hass: HomeAssistant) -> IssueRegistry: return IssueRegistry(hass) -async def async_load(hass: HomeAssistant, *, read_only: bool = False) -> None: +async def async_load( + hass: HomeAssistant, + *, + read_only: bool = False, + load_empty: bool = False, +) -> None: """Load issue registry.""" ir = async_get(hass) if read_only: # only used in for check config script ir.make_read_only() - return await ir.async_load() + await ir.async_load(load_empty=load_empty) @callback diff --git a/homeassistant/helpers/label_registry.py b/homeassistant/helpers/label_registry.py index 33a05156328021..a010347a7a508b 100644 --- a/homeassistant/helpers/label_registry.py +++ b/homeassistant/helpers/label_registry.py @@ -80,7 +80,7 @@ async def _async_migrate_func( ) -> LabelRegistryStoreData: """Migrate to the new version.""" if old_major_version > STORAGE_VERSION_MAJOR: - raise ValueError("Can't migrate to future version") + raise NotImplementedError if old_major_version == 1: if old_minor_version < 2: @@ -224,7 +224,7 @@ def async_update( return new - async def async_load(self) -> None: + async def _async_load(self) -> None: """Load the label registry.""" data = await self._store.async_load() labels = NormalizedNameBaseRegistryItems[LabelEntry]() @@ -270,7 +270,7 @@ def async_get(hass: HomeAssistant) -> LabelRegistry: return LabelRegistry(hass) -async def async_load(hass: HomeAssistant) -> None: +async def async_load(hass: HomeAssistant, *, load_empty: bool = False) -> None: """Load label registry.""" assert DATA_REGISTRY not in hass.data - await async_get(hass).async_load() + await async_get(hass).async_load(load_empty=load_empty) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index ab1a3dfa54c16f..c9ca479df8ea91 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -659,26 +659,34 @@ def _get_exposed_entities( continue entity_entry = entity_registry.async_get(state.entity_id) - names = [state.name] + device_entry = ( + device_registry.async_get(entity_entry.device_id) + if entity_entry is not None and entity_entry.device_id is not None + else None + ) + names = intent.async_get_entity_aliases(hass, entity_entry, state=state) area_names = [] if entity_entry is not None: - names.extend(entity_entry.aliases) - if entity_entry.area_id and ( - area := area_registry.async_get_area(entity_entry.area_id) + if ( + entity_entry.area_id is not None + and (area_entry := area_registry.async_get_area(entity_entry.area_id)) + is not None ): # Entity is in area - area_names.append(area.name) - area_names.extend(area.aliases) - elif entity_entry.device_id and ( - device := device_registry.async_get(entity_entry.device_id) - ): + area_names.append(area_entry.name) + area_names.extend(area_entry.aliases) + elif device_entry is not None: # Check device area - if device.area_id and ( - area := area_registry.async_get_area(device.area_id) + if ( + device_entry.area_id is not None + and ( + area_entry := area_registry.async_get_area(device_entry.area_id) + ) + is not None ): - area_names.append(area.name) - area_names.extend(area.aliases) + area_names.append(area_entry.name) + area_names.extend(area_entry.aliases) info: dict[str, Any] = { "names": ", ".join(names), @@ -919,12 +927,10 @@ def on_homeassistant_close(event: Event) -> None: entity_registry = er.async_get(hass) if ( entity_id := entity_registry.async_get_entity_id(domain, domain, action) - ) and (entity_entry := entity_registry.async_get(entity_id)): - aliases: list[str] = [] - if entity_entry.name: - aliases.append(entity_entry.name) - if entity_entry.aliases: - aliases.extend(entity_entry.aliases) + ) is not None and ( + entity_entry := entity_registry.async_get(entity_id) + ) is not None: + aliases = er.async_get_entity_aliases(hass, entity_entry) if aliases: if description: description = description + ". Aliases: " + str(list(aliases)) diff --git a/homeassistant/helpers/registry.py b/homeassistant/helpers/registry.py index 6c5fd117140f67..1fee41d3293a86 100644 --- a/homeassistant/helpers/registry.py +++ b/homeassistant/helpers/registry.py @@ -77,6 +77,19 @@ def async_schedule_save(self) -> None: delay = SAVE_DELAY if self.hass.state is CoreState.running else SAVE_DELAY_LONG self._store.async_delay_save(self._data_to_save, delay) + async def async_load(self, *, load_empty: bool = False) -> None: + """Load the registry. + + Optionally set the store to load empty and become read-only. + """ + if load_empty: + self._store.set_load_empty() + await self._async_load() + + @abstractmethod + async def _async_load(self) -> None: + """Load the registry.""" + @abstractmethod def _data_to_save(self) -> _StoreDataT: """Return data of registry to store in a file.""" diff --git a/homeassistant/helpers/reload.py b/homeassistant/helpers/reload.py index cdd53731d6eb86..0e33fedb28e2d5 100644 --- a/homeassistant/helpers/reload.py +++ b/homeassistant/helpers/reload.py @@ -136,6 +136,8 @@ async def _async_reconfig_platform( await asyncio.gather(*tasks) +# The complicated overloads are due to a limitation in mypy, details in +# https://github.com/python/mypy/issues/7333 @overload async def async_integration_yaml_config( hass: HomeAssistant, integration_name: str diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index 78812061a03f5f..81e9d7ed68e427 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -9,7 +9,7 @@ from homeassistant.const import ATTR_RESTORED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, State, callback, valid_entity_id -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, UnsupportedStorageVersionError from homeassistant.util import dt as dt_util from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import json_loads @@ -95,9 +95,12 @@ def from_dict(cls, json_dict: dict) -> Self: ) -async def async_load(hass: HomeAssistant) -> None: +async def async_load(hass: HomeAssistant, *, load_empty: bool = False) -> None: """Load the restore state task.""" - await async_get(hass).async_setup() + data = async_get(hass) + if load_empty: + data.set_load_empty() + await data.async_setup() @callback @@ -124,6 +127,10 @@ def __init__(self, hass: HomeAssistant) -> None: self.last_states: dict[str, StoredState] = {} self.entities: dict[str, RestoreEntity] = {} + def set_load_empty(self) -> None: + """Set the store to load empty and become read-only.""" + self.store.set_load_empty() + async def async_setup(self) -> None: """Set up up the instance of this data helper.""" await self.async_load() @@ -139,6 +146,8 @@ async def async_load(self) -> None: """Load the instance of this data helper.""" try: stored_states = await self.store.async_load() + except UnsupportedStorageVersionError: + raise except HomeAssistantError as exc: _LOGGER.error("Error loading last states", exc_info=exc) stored_states = None @@ -172,15 +181,24 @@ def async_get_stored_states(self) -> list[StoredState]: } # Start with the currently registered states - stored_states = [ - StoredState( - current_states_by_entity_id[entity_id], - entity.extra_restore_state_data, - now, + stored_states: list[StoredState] = [] + for entity_id, entity in self.entities.items(): + if entity_id not in current_states_by_entity_id: + continue + try: + extra_data = entity.extra_restore_state_data + except Exception: + _LOGGER.exception( + "Error getting extra restore state data for %s", entity_id + ) + continue + stored_states.append( + StoredState( + current_states_by_entity_id[entity_id], + extra_data, + now, + ) ) - for entity_id, entity in self.entities.items() - if entity_id in current_states_by_entity_id - ] expiration_time = now - STATE_EXPIRATION for entity_id, stored_state in self.last_states.items(): @@ -210,6 +228,8 @@ async def async_dump_states(self) -> None: ) except HomeAssistantError as exc: _LOGGER.error("Error saving current states", exc_info=exc) + except Exception: + _LOGGER.exception("Unexpected error saving current states") @callback def async_setup_dump(self, *args: Any) -> None: @@ -249,13 +269,15 @@ def async_restore_entity_added(self, entity: RestoreEntity) -> None: @callback def async_restore_entity_removed( - self, entity_id: str, extra_data: ExtraStoredData | None + self, + entity_id: str, + state: State | None, + extra_data: ExtraStoredData | None, ) -> None: """Unregister this entity from saving state.""" # When an entity is being removed from hass, store its last state. This # allows us to support state restoration if the entity is removed, then # re-added while hass is still running. - state = self.hass.states.get(entity_id) # To fully mimic all the attribute data types when loaded from storage, # we're going to serialize it to JSON and then re-load it. if state is not None: @@ -278,8 +300,18 @@ async def async_internal_added_to_hass(self) -> None: async def async_internal_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" + try: + extra_data = self.extra_restore_state_data + except Exception: + _LOGGER.exception( + "Error getting extra restore state data for %s", self.entity_id + ) + state = None + extra_data = None + else: + state = self.hass.states.get(self.entity_id) async_get(self.hass).async_restore_entity_removed( - self.entity_id, self.extra_restore_state_data + self.entity_id, state, extra_data ) await super().async_internal_will_remove_from_hass() diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 34c9446c3de268..ed33fd61f6c3a6 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -119,6 +119,13 @@ def _validate_supported_features(supported_features: list[str]) -> int: return feature_mask +def _validate_selector_reorder_config(config: Any) -> Any: + """Validate selectors with reorder option.""" + if config.get("reorder") and not config.get("multiple"): + raise vol.Invalid("reorder can only be used when multiple is true") + return config + + def make_selector_config_schema(schema_dict: dict | None = None) -> vol.Schema: """Make selector config schema.""" if schema_dict is None: @@ -158,10 +165,23 @@ class BaseSelectorConfig(TypedDict, total=False): vol.Optional("supported_features"): [ vol.All(cv.ensure_list, [str], _validate_supported_features) ], + # Unit of measurement of the entity + vol.Optional(CONF_UNIT_OF_MEASUREMENT): vol.All(cv.ensure_list, [str]), } ) +class _LegacyEntityFilterSelectorConfig(TypedDict, total=False): + """Class for legacy entity filter support in EntitySelectorConfig. + + Provided for backwards compatibility and remains feature frozen. + """ + + integration: str + domain: str | list[str] + device_class: str | list[str] + + # Legacy entity selector config schema used directly under entity selectors # is provided for backwards compatibility and remains feature frozen. # New filtering features should be added under the `filter` key instead. @@ -183,6 +203,7 @@ class EntityFilterSelectorConfig(TypedDict, total=False): domain: str | list[str] device_class: str | list[str] supported_features: list[str] + unit_of_measurement: str | list[str] DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( @@ -301,6 +322,7 @@ class AreaSelectorConfig(BaseSelectorConfig, total=False): entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] device: DeviceFilterSelectorConfig | list[DeviceFilterSelectorConfig] multiple: bool + reorder: bool @SELECTORS.register("area") @@ -309,18 +331,22 @@ class AreaSelector(Selector[AreaSelectorConfig]): selector_type = "area" - CONFIG_SCHEMA = make_selector_config_schema( - { - vol.Optional("entity"): vol.All( - cv.ensure_list, - [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA], - ), - vol.Optional("device"): vol.All( - cv.ensure_list, - [DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA], - ), - vol.Optional("multiple", default=False): cv.boolean, - } + CONFIG_SCHEMA = vol.All( + make_selector_config_schema( + { + vol.Optional("entity"): vol.All( + cv.ensure_list, + [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA], + ), + vol.Optional("device"): vol.All( + cv.ensure_list, + [DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA], + ), + vol.Optional("multiple", default=False): cv.boolean, + vol.Optional("reorder", default=False): cv.boolean, + } + ), + _validate_selector_reorder_config, ) def __init__(self, config: AreaSelectorConfig | None = None) -> None: @@ -838,6 +864,7 @@ class DurationSelectorConfig(BaseSelectorConfig, total=False): """Class to represent a duration selector config.""" enable_day: bool + enable_second: bool enable_millisecond: bool allow_negative: bool @@ -853,6 +880,8 @@ class DurationSelector(Selector[DurationSelectorConfig]): # Enable day field in frontend. A selection with `days` set is allowed # even if `enable_day` is not set vol.Optional("enable_day"): cv.boolean, + # Enable seconds field in frontend. + vol.Optional("enable_second", default=True): cv.boolean, # Enable millisecond field in frontend. vol.Optional("enable_millisecond"): cv.boolean, # Allow negative durations. @@ -873,9 +902,15 @@ def __call__(self, data: Any) -> dict[str, float]: return cast(dict[str, float], data) -class EntitySelectorConfig(BaseSelectorConfig, EntityFilterSelectorConfig, total=False): +class EntitySelectorConfig( + BaseSelectorConfig, _LegacyEntityFilterSelectorConfig, total=False +): """Class to represent an entity selector config.""" + # Note: The class inherits _LegacyEntityFilterSelectorConfig to keep + # support for legacy entity filter at top level for backwards compatibility, + # new entity filter options should be added under the `filter` key instead. + exclude_entities: list[str] include_entities: list[str] multiple: bool @@ -889,18 +924,21 @@ class EntitySelector(Selector[EntitySelectorConfig]): selector_type = "entity" - CONFIG_SCHEMA = make_selector_config_schema( - { - **_LEGACY_ENTITY_SELECTOR_CONFIG_SCHEMA_DICT, - vol.Optional("exclude_entities"): [str], - vol.Optional("include_entities"): [str], - vol.Optional("multiple", default=False): cv.boolean, - vol.Optional("reorder", default=False): cv.boolean, - vol.Optional("filter"): vol.All( - cv.ensure_list, - [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA], - ), - } + CONFIG_SCHEMA = vol.All( + make_selector_config_schema( + { + **_LEGACY_ENTITY_SELECTOR_CONFIG_SCHEMA_DICT, + vol.Optional("exclude_entities"): [str], + vol.Optional("include_entities"): [str], + vol.Optional("multiple", default=False): cv.boolean, + vol.Optional("reorder", default=False): cv.boolean, + vol.Optional("filter"): vol.All( + cv.ensure_list, + [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA], + ), + } + ), + _validate_selector_reorder_config, ) def __init__(self, config: EntitySelectorConfig | None = None) -> None: @@ -1490,6 +1528,7 @@ class StateSelectorConfig(BaseSelectorConfig, total=False): entity_id: str hide_states: list[str] + attribute: str multiple: bool @@ -1512,11 +1551,7 @@ class StateSelector(Selector[StateSelectorConfig]): { vol.Optional("entity_id"): cv.entity_id, vol.Optional("hide_states"): [str], - # The attribute to filter on, is currently deliberately not - # configurable/exposed. We are considering separating state - # selectors into two types: one for state and one for attribute. - # Limiting the public use, prevents breaking changes in the future. - # vol.Optional("attribute"): str, + vol.Optional("attribute"): str, vol.Optional("multiple", default=False): cv.boolean, } ) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index bcb1367020c576..d7484f214fb4cf 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -782,6 +782,8 @@ async def entity_service_call( all_referenced, ) + entity_candidates = [e for e in entity_candidates if e.available] + if not target_all_entities: assert referenced is not None # Only report on explicit referenced entities @@ -792,9 +794,6 @@ async def entity_service_call( entities: list[Entity] = [] for entity in entity_candidates: - if not entity.available: - continue - # Skip entities that don't have the required device class. if ( entity_device_classes is not None diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index bf325685caeb2c..d651f6c36c4347 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -28,7 +28,7 @@ HomeAssistant, callback, ) -from homeassistant.exceptions import HomeAssistantError +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 @@ -239,6 +239,7 @@ def __init__( *, atomic_writes: bool = False, encoder: type[JSONEncoder] | None = None, + max_readable_version: int | None = None, minor_version: int = 1, read_only: bool = False, serialize_in_event_loop: bool = True, @@ -246,6 +247,10 @@ def __init__( """Initialize storage class. Args: + max_readable_version: Maximum major version that can be read. Defaults + to version. Set higher than version to support forward compatibility, + allowing reading data written by newer versions (e.g., after downgrade). + serialize_in_event_loop: Whether to serialize data in the event loop. Set to True (default) if data passed to async_save and data produced by data_func passed to async_delay_save needs to be serialized in the event @@ -273,6 +278,10 @@ def __init__( self._encoder = encoder self._atomic_writes = atomic_writes self._read_only = read_only + self._load_empty = False + self._max_readable_version = ( + max_readable_version if max_readable_version is not None else version + ) self._next_write_time = 0.0 self._manager = get_internal_store_manager(hass) self._serialize_in_event_loop = serialize_in_event_loop @@ -289,6 +298,14 @@ def make_read_only(self) -> None: """ self._read_only = True + def set_load_empty(self) -> None: + """Set the store to load empty data and become read-only. + + When set, the store will skip loading data from disk and return None, + while also becoming read-only to preserve on-disk data untouched. + """ + self._load_empty = True + async def async_load(self) -> _T | None: """Load data. @@ -328,6 +345,12 @@ async def _async_load(self) -> _T | None: async def _async_load_data(self): """Load the data.""" + # When load_empty is set, skip loading storage files and use empty + # data while preserving the on-disk files untouched. + if self._load_empty: + self.make_read_only() + return None + # Check if we have a pending write if self._data is not None: data = self._data @@ -415,6 +438,10 @@ async def _async_load_data(self): ): stored = data["data"] else: + if data["version"] > self._max_readable_version: + raise UnsupportedStorageVersionError( + self.key, data["version"], self._max_readable_version + ) _LOGGER.info( "Migrating %s storage from %s.%s to %s.%s", self.key, diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index 13d67e6939d43c..05f16f4355e518 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -8,6 +8,7 @@ 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 import logging @@ -57,7 +58,10 @@ 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 +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 @@ -807,6 +811,48 @@ def __repr__(self) -> str: return "